@craft-ts/mcp 0.7.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/content/agents.md +35 -0
- package/content/best-practices.md +79 -0
- package/content/docs-index.json +658 -0
- package/dist/catalog.d.ts +27 -0
- package/dist/catalog.js +67 -0
- package/dist/catalog.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +13 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.js +144 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/resources.d.ts +10 -0
- package/dist/resources.js +63 -0
- package/dist/resources.js.map +1 -0
- package/mcp.json +10 -0
- package/package.json +62 -0
- package/plugin.json +19 -0
- package/skills/craft-ts/SKILL.md +43 -0
- package/skills/craft-ts-architecture-tests/SKILL.md +110 -0
- package/skills/craft-ts-effect-v4/SKILL.md +31 -0
- package/skills/craft-ts-routes/SKILL.md +135 -0
- package/skills/craft-ts-routes/references/di-checks.md +101 -0
- package/skills/craft-ts-routes/references/eslint-workflow.md +52 -0
- package/skills/craft-ts-routes/references/pending-and-exceptions.md +93 -0
- package/skills/craft-ts-routes/references/scaling-and-pitfalls.md +111 -0
- package/skills/craft-ts-service-migration/SKILL.md +86 -0
- package/skills/migrate-to-craft-ts/SKILL.md +135 -0
- package/skills/translate-spec-to-craft-ts/SKILL.md +86 -0
- package/skills/translate-spec-to-craft-ts/references/lexical-map.md +322 -0
- package/skills/translate-spec-to-craft-ts/references/pattern-recipes.md +123 -0
- package/skills/translate-spec-to-craft-ts/references/project-index.md +52 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"path": "/",
|
|
4
|
+
"title": "/",
|
|
5
|
+
"body": "## Packages\n\nThe toolkit is split into focused packages. They are currently published on the\n`beta` channel. Coding agents should start from\n[`llms.txt`](https://craft-ts.github.io/craft/llms.txt) and the\n[coding agents](/resources/ai-agents) guide.\n\n| Package | Purpose |\n| -------------------------------------------------------------------------- | ------------------------------------------------------------------- |\n| [`@craft-ts/core`](https://www.npmjs.com/package/@craft-ts/core) | Reactive primitives, services, forms, routing and testing utilities |\n| [`@craft-ts/component`](https://www.npmjs.com/package/@craft-ts/component) | Selectorless functional components and typed hyperscript templates |\n| [`@craft-ts/dev-tools`](https://www.npmjs.com/package/@craft-ts/dev-tools) | Codemods, generators, CLI commands and ESLint rules |\n| [`@craft-ts/effect`](https://www.npmjs.com/package/@craft-ts/effect) | Effect bridge, Layers and Effect-aware reactive resources |\n| [`@craft-ts/mcp`](https://www.npmjs.com/package/@craft-ts/mcp) | MCP server, Agent Skills and `llms.txt` helpers for coding agents |\n\n<AuthorNote />\n"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"path": "/guide",
|
|
9
|
+
"title": "Guide",
|
|
10
|
+
"body": "# Guide\n\nThe guide is organised by **what you are trying to do**. If you are starting\nout, the [Learn path](/learn/) is a better entry point — it introduces the same\nmaterial one idea at a time.\n\n## Start here\n\nFour pages carry most of the weight. Reading them in this order is worth an\nafternoon:\n\n1. [The mental model](/guide/concepts/mental-model) — the principles the API\n follows and the guarantees they provide\n2. [Which primitive should I use?](/guide/concepts/choose-primitive) — the\n five-way decision you make constantly\n3. [Anatomy of a primitive](/guide/concepts/primitive-anatomy) — the shape all\n five share\n4. [Generators and `yield*`](/guide/concepts/generators) — the tracking channel\n everything is built on\n5. [Insertions](/guide/concepts/insertions) — how behaviour is composed\n\n## By topic\n\n### Managing state\n\n[Local state](/guide/state/local-state) ·\n[query](/guide/state/server-state) ·\n[Mutations](/guide/state/mutations) ·\n[queryParams](/guide/state/url-state) ·\n[asyncProcess](/guide/state/async-process) ·\n[Collections](/guide/state/collections) ·\n[Persistence](/guide/state/persistence) ·\n[Selecting](/guide/state/select) ·\n[Reacting to mutations](/guide/state/react-on-mutation) ·\n[Schema validation](/guide/state/schema-validation)\n\n### Structuring the app\n\n[craftService](/guide/app/craft-service) ·\n[Service scopes](/guide/app/service-scopes) ·\n[Shaping the public API](/guide/app/expose-api) ·\n[Abstract services](/guide/app/abstract-services) ·\n[App start](/guide/app/app-start) ·\n[Lazy services](/guide/app/lazy-services)\n\n### Recommended approaches\n\n[Inject at the point of use](/guide/patterns/inject-at-point-of-use)\n\n### Routing and type-safe DI\n\n[Setup](/guide/routing/setup) ·\n[CLI automation](/guide/routing/automation) ·\n[ESLint rules](/guide/routing/eslint-rules) ·\n[Route providers](/guide/routing/route-providers) ·\n[Guards](/guide/routing/guards) ·\n[Exception handling](/guide/routing/exception-handling) ·\n[Pending UI](/guide/routing/pending-ui) ·\n[Route load errors](/guide/routing/route-load-errors) ·\n[Scaling routes](/guide/routing/scaling)\n\n### Components and templates\n\n[Components](/guide/components/) ·\n[Fine-grained reactivity](/guide/components/fine-grained-reactivity) ·\n[Progressive `each`](/guide/components/schedule-each) ·\n[Directives and `.pipe(...)`](/guide/components/directives) ·\n[Customization](/guide/components/customization) ·\n[Content projection](/guide/components/content-projection) ·\n[Encapsulated styles](/guide/components/styles) ·\n[Accessibility](/guide/components/accessibility)\n\n### Forms\n\n[Overview](/guide/forms/) ·\n[Validators](/guide/forms/validation) ·\n[Submitting](/guide/forms/submit) ·\n[Nested forms](/guide/forms/nested)\n\n### Testing\n\n[Services](/guide/testing/services) ·\n[Components](/guide/testing/components) ·\n[Type-level tests](/guide/testing/type-level) ·\n[Browser boundaries](/guide/testing/browser-boundaries) ·\n[Architecture rules](/guide/testing/architecture) ·\n[Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n\n### Reactivity utilities\n\n[craftComputed](/guide/reactivity/craft-computed) ·\n[craftEffect](/guide/reactivity/craft-effect) ·\n[craftMethod](/guide/reactivity/craft-method) ·\n[source$](/guide/reactivity/source) ·\n[on$](/guide/reactivity/on)\n\n### Going further\n\n[SSR and hydration](/guide/advanced/ssr-hydration) ·\n[Program operators](/guide/advanced/program-operators) ·\n[Pattern matching](/guide/advanced/pattern-matching) ·\n[Observability](/guide/advanced/observability) ·\n[Live page MCP](/guide/ai/dev-page) ·\n[Coding agents](/resources/ai-agents)\n\n## Looking for one symbol?\n\nThe [API index](/reference/) lists every export with a one-line description.\n"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "/guide/advanced/effect",
|
|
14
|
+
"title": "Using Effect with CraftTS",
|
|
15
|
+
"body": "# Using Effect with CraftTS\n\nEffect belongs in CraftTS when the problem is a **domain program**: composing\nservices, modelling typed failures, controlling resources, or running an\noperation that crosses an I/O boundary. CraftTS remains responsible for\ncomponents, fine-grained rendering, reactive state and resource lifecycles.\n\nThe integration is deliberately a boundary, not a second UI runtime:\n\n```text\nCraft component / template\n ↓\nCraft primitive or generator\n ↓\nEffect<A, E, R>\n ↓\nLayer<R> provided by the Craft injector\n```\n\nIf a value is only local UI state, keep it in Craft. If it is a domain operation\nwith typed errors or services, define it as an Effect and adapt it at the Craft\nboundary.\n\n## The short decision\n\n| Need | Use | Why |\n| ------------------------------------------------ | -------------------------------- | ---------------------------------------------------------------- |\n| Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state |\n| Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns |\n| Derive a reactive value with an Effect | `computedEffect` | reruns an Effect factory when Craft dependencies change |\n| Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions |\n| Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process |\n| Provide Effect services | `provideLayer` | app and route injectors own Layer scope |\n| Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members |\n| Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions |\n| Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect |\n\nThere is intentionally no `stateEffect`. A reactive value is not made better by\nbeing an Effect. Use `state` for the value, and use Effect for the computation\nthat loads or changes it.\n\n## Install the packages\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\n```\n\nKeep the three Craft packages on the same version. `@craft-ts/effect` declares\n`effect` as a peer dependency.\n\n## Install the bridge once\n\nThe bridge teaches Craft's generator driver how to execute a yielded Effect.\nInstall it at application bootstrap:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn a test, install it in `beforeEach` and call the returned disposer in\n`afterEach`. Do not install a new bridge in every loader or component.\n\n## Keep components in Craft\n\nA Craft component still has a generator factory and a typed template. The\ncomponent should call a domain operation, not resolve its repository or start a\nfiber from a click handler:\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile } from './profile-domain';\n\nexport const Profile = craftComponent(\n 'Profile',\n {},\n function* () {\n const profile = yield* queryEffect('profile', {\n params: () => 'user-ada',\n loader: ({ params }) => loadUserProfile(params),\n });\n\n return { profile };\n },\n ({ profile }) => [\n p(function* () {\n const user = yield* profile.value();\n return user?.name ?? 'Loading…';\n }),\n button(\n 'reload',\n {\n *click() {\n yield* profile.reload();\n },\n },\n 'Reload',\n ),\n ],\n);\n```\n\nThe template consumes Craft readers. It does not subscribe to an Effect, call\n`Effect.runPromise`, or convert a Promise into a signal manually.\n\n## Define the domain in Effect\n\nUse Effect for domain contracts and implementations. Tagged errors are values in\nthe `E` channel:\n\n```typescript\nimport { Context, Data, Effect, Layer } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport type UserRepository = {\n readonly byId: (userId: string) => Effect.Effect<User, UserNotFound>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({\n byId: (userId) => findUserInDatabase(userId),\n}));\n\nexport function loadUserProfile(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n return yield* repository.byId(userId);\n });\n}\n```\n\nThe resulting program carries its success value, its typed failures and its\nrequirements. A Craft component only needs `loadUserProfile`; it does not need\nto know which Layer implements `UserRepositoryService`.\n\n## Choose the right adapter\n\n### `queryEffect`: Effect-backed reads\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) => listUsers(params),\n });\n```\n\nUse it when the result is server or domain state. Craft owns `status`, loading,\nprevious value, cancellation and reloading. The loader returns\n`Effect<Value, Error, Requirements>`.\n\nThe `params` factory and `method` are synchronous. They may read Craft\ndependencies, but must not create an Effect or read an Effect service. The\nloader is the only Effect-aware callback:\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: function* () {\n const input = yield* searchInput();\n return resolveSearchParams(input);\n },\n loader: ({ params }) => listUsers(params),\n });\n```\n\nThe Effect ESLint rule enforces this boundary. For an asynchronous derived\ninput, use `computedEffect` and feed its resolved Craft value to a synchronous\n`params` function.\n\n### `mutationEffect`: Effect-backed writes\n\n```typescript\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n method: (input: UserInput) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nTrigger it with `yield* saveUser.mutate(input)`. Use the normal Craft\n`insertReactOnMutation` insertion to reload a query or apply an optimistic patch.\nThe mutation `method` only maps its arguments to synchronous params. The\n`loader` is the only Effect-aware callback.\n\n### `asyncProcessEffect`: explicit commands\n\n```typescript\nconst exportUsers =\n yield *\n asyncProcessEffect('exportUsers', {\n method: (filter: Filter) => filter,\n loader: ({ params }) => exportUsersEffect(params),\n });\n\nyield * exportUsers.method(currentFilter);\n```\n\nThe `asyncProcessEffect` method follows the same rule: it returns plain params;\nthe loader owns the asynchronous Effect program.\n\nUse it for an operation with a lifecycle but without a query cache or mutation\nrelationship.\n\n### `runEffect`: the low-level form\n\nUse `runEffect` when an Effect is yielded directly by a guard, resolver or Craft\nprogram and you need its typed errors to be visible to Craft:\n\n```typescript\nimport { runEffect } from '@craft-ts/effect';\n\nconst user = yield * runEffect(loadUserProfile(userId));\n```\n\nThe adapter is the right choice for most component resources. A bare\n`yield* someEffect` may execute at runtime, but it does not advertise the\nEffect's `E` channel to Craft's route-exception analysis. `runEffect` does.\n\n## Provide services with `Layer`\n\n`provideLayer` attaches a built Effect context to a Craft injector:\n\n```typescript\nexport const appConfig = craftAppConfig({\n providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))],\n});\n```\n\nUse one merged Layer per injector level. A route can add a narrower Layer:\n\n```typescript\nconst routeProviders = [provideLayer(TeamContextLive)] as const;\n\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(() => import('./team'), routeProviders),\n },\n]);\n```\n\nThe parent context is reused and the child Layer is added for that route. Its\nEffect scope is closed with the route injector.\n\nFor compile-time coverage, compare the program's `Effect.Services<...>` with\nthe values provided by the app and route:\n\n```typescript\ntype Check = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices | ProvidedEffectServicesOf<typeof routeProviders>\n>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nSee [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for\nthe full `AppProvidedDependencyValuesOf` setup.\n\n## Understand the error mapping\n\nThe bridge keeps Effect's distinctions intact:\n\n| Effect outcome | Craft outcome | Handle it with |\n| -------------------------- | ------------------------------------- | ---------------------------------------- |\n| `Effect.succeed(value)` | resource value / generator result | normal rendering |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchBlock`, `catchTag`, route handlers |\n| `Effect.die(defect)` | technical error | error boundary / monitoring |\n| interruption | cancellation | normally no user-facing handler |\n\nUse exhaustive matching for business errors:\n\n```typescript\nmatchBlock.exhaustive(resource.exception, '_tag', {\n UserNotFound: () => p('No user was found.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nThe error union is only visible to the compiler when the Effect crosses through\n`queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`.\n\n## Use Effect Schema at data boundaries\n\n`@craft-ts/core` accepts Standard Schema. Effect Schema participates through one\nconversion call:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst UserInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n name: Schema.String,\n email: Schema.String,\n }),\n);\n\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n methodSchema: UserInput,\n method: (input) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nThis schema interop does not require `@craft-ts/effect`; it follows the Standard\nSchema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema)\nfor async decoding and loader result validation.\n\n## Select an Effect service from Craft\n\nMost components should consume a domain operation. A Craft service or adapter\nthat really needs an Effect service can select only the members it uses:\n\n```typescript\nconst { byId } =\n yield * effectService(UserRepositoryService, ({ byId }) => ({ byId }));\n```\n\nThe selection narrows the graph and keeps generic member signatures intact. It\ndoes not replace `Layer`; the service still comes from the nearest\n`provideLayer(...)`.\n\n## Testing\n\nUse `mockEffectService` for a focused Layer:\n\n```typescript\nconst repository = mockEffectService(UserRepositoryService, {\n byId: () => Effect.succeed(expectedUser),\n});\n```\n\nCombine it with Craft's register-based tests. The Effect mock covers the Effect\nservice; the Craft register covers every Craft dependency and boundary. An\nunstubbed member fails with `UnstubbedEffectMember` instead of silently returning\nan incomplete value.\n\nSee [testing with Effect](/learn-effect/08-testing) and [browser\nboundaries](/guide/testing/browser-boundaries).\n\n## Package map\n\n| Package | Responsibility |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------- |\n| `@craft-ts/component` | functional Craft components and typed templates |\n| `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry |\n| `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers |\n| `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime |\n| `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment |\n| `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks |\n\nInstall only the packages needed by the layer you are building. For example,\nEffect Schema validation can be used with `@craft-ts/core` alone; the bridge and\nEffect-aware resource adapters require `@craft-ts/effect`.\n\n## Server functions: current POC\n\nThe current server-function integration is a **proof of concept**, not a final\nAPI. It currently combines:\n\n- `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`;\n- `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`;\n- `Effect`/`Layer` on the server;\n- a local HTTP transport and `@effect/platform-node` in the demo.\n\nThe client must import only the server function's type, while the server owns\nthe implementation and server-only Layers. Authentication and authorization\nmust be checked again on the server; a client Layer is never a security boundary.\n\nSee the [server functions POC chapter](/learn-effect/09-server-functions) and\nthe [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function).\nExpect the transport, file conventions, middleware API and production\nintegration to change before this becomes a stable feature.\n\n## Common mistakes\n\n- **Putting every value in Effect:** keep local UI state in `state` and URL state\n in `queryParams`.\n- **Subscribing in a component:** return an Effect from a resource adapter and\n let Craft own loading and cancellation.\n- **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI\n can handle it exhaustively.\n- **Providing a Layer inside a loader:** provide it at app or route scope so its\n lifetime and requirements are visible.\n- **Trusting client context in a server function:** treat it as a claim and\n verify it on the server.\n- **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft\n sees the typed exception union.\n\n## See also\n\n- [Learn CraftTS with Effect](/learn-effect/)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Exceptions as values](/guide/concepts/exceptions)\n- [Program operators](/guide/advanced/program-operators)\n- [Effect Schema](/guide/state/schema-validation#effect-schema)\n- [Effect integration tests](/learn-effect/08-testing)\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "/guide/advanced/observability",
|
|
19
|
+
"title": "Observability",
|
|
20
|
+
"body": "# Observability\n\nBecause every dependency is resolved through one system, that system is also the\nplace to cross-cut them all — logging, timing, correlation ids, snapshots — with\nno change to the business code.\n\n**Use it when** you need to see what your app is doing in production, or to\nconnect craft to your monitoring stack.\n**Start with `Console`**: it is yieldable, so overriding it once redirects every\nlog in the app.\n\nThe same DI system that powers `craftService` also lets you cross-cut every crafted function with side effects — logging, snapshots, correlation tracking, timing, error reporting — without touching the business code.\n\n## Mental Model\n\n`craft-ts` distinguishes two kinds of failures:\n\n- **Expected errors**: handled explicitly with [`craftException`](/guide/app/craft-service) in your business code.\n- **Unexpected errors**: bugs. They should never happen — and if they do, they should never happen _again_.\n\nUnexpected errors are exactly where observability shines. Since they are supposed to be impossible, you want to capture the maximum amount of context the moment one is thrown: stack, app state, correlation chain, etc. That context can then be shipped to a log server, an alerting pipeline, or directly to an AI webhook for triage.\n\nThe three pillars `craft-ts` exposes for that are:\n\n- [`provideFnWrapper`](#providefnwrapper) — wrap every crafted function with cross-cutting behavior\n- [`provideTemplateTrace`](#providetemplatetrace) — observe effective component and template renders\n- [`provideCraftRouterTrace`](#providecraftroutertrace) — observe navigation events and Craft route stages\n- [`provideCraftHttpTrace`](#providecrafthttptrace) — wrap every `CraftHttpClient` request\n- [`provideTakeAppSnapshot`](#providetakeappsnapshot) — capture all active state when something goes wrong\n- [`provideCraftDomEventHook`](#craft-dom-event-hooks) — observe or wrap every DOM action declared in a Craft template\n- [`provideCorrelationIdTracking`](#providecorrelationidtracking) — link a user gesture to every async operation it triggered\n\n## `provideFnWrapper`\n\n`provideFnWrapper` lets you wrap **every** generator-based function executed by `craft-ts` (services, methods, async processes, queries, mutations, effects…). It is the single best entry point to add cross-cutting side effects.\n\nBasic use case — log any unexpected error to the console:\n\n```ts\nimport { craftAppConfig, provideFnWrapper, Console } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n ],\n});\n```\n\nYou can register multiple wrappers — they compose. The first registered is the outermost.\n\n## `provideTemplateTrace`\n\n`provideTemplateTrace` is the render-specific counterpart to\n`provideFnWrapper`. It runs synchronously around the children produced by an\neffective render, including component templates, reactive updates, blocks,\nprojections, deferred branches, and nested callbacks.\n\n```ts\nimport { provideTemplateTrace } from '@craft-ts/core';\n\nprovideTemplateTrace((context, next) => {\n const start = performance.now();\n try {\n return next();\n } finally {\n console.debug(\n context.phase,\n context.componentName,\n performance.now() - start,\n );\n }\n});\n```\n\nThe context contains the render unit (`component`, `block`, `projection`,\n`defer`, or `callback`), its phase (`create`, `initialRender`, `update`, or\n`destroy`), the optional component/unit names, and the owning component's\n`renderCount`. Wrappers compose in registration order and execute in the\ncurrent render injector, so component-scoped providers remain injectable.\n\nThe wrapper can return different children or return an empty children value\nwithout calling `next()` to replace or block a render. Errors propagate to the\nnormal Craft render error boundary.\n\n## `provideCraftRouterTrace`\n\n`provideCraftRouterTrace` traces both the Router event stream and the\nCraft outlet's non-blocking route chain. The latter exposes `match`, `guard`,\nand `resolve` stages, including reactive guard re-evaluation.\n\n```ts\nimport { provideCraftRouterTrace } from '@craft-ts/core';\n\nprovideCraftRouterTrace((context, next) => {\n console.log('[router:start]', context);\n const result = next();\n console.log('[router:end]', context);\n return result;\n});\n```\n\nMultiple wrappers compose in registration order. The wrapper must call\n`next()` to preserve the navigation or route-chain work.\n\n## `provideCraftHttpTrace`\n\n`provideCraftHttpTrace` wraps the actual thenable request produced by\n`CraftHttpClient`, after its method, URL, params, and payload have been built.\nIt is therefore useful for timing, request logging, redaction, and error\nreporting without changing feature code.\n\n```ts\nimport { provideCraftHttpTrace } from '@craft-ts/core';\n\nprovideCraftHttpTrace(async (context, next) => {\n const start = performance.now();\n try {\n return await next();\n } finally {\n console.log(context.method, context.url, performance.now() - start);\n }\n});\n```\n\n### Important: injection inside `provideFnWrapper` is not type-safe\n\nThe wrapper body runs in **the injection context where the error was raised**, not where the wrapper was declared. That makes it extremely practical: you can yield browser boundaries, inject host-tagged metadata, read the offending service's correlation id, etc.\n\nBut it has two consequences:\n\n- injections inside the wrapper are **not type-safe** — `craft-ts` cannot prove statically that the dependency you ask for is actually provided where the wrapper runs\n- the wrapper is therefore a **risky** place to do business work\n\n::: tip\nUse `provideFnWrapper` mostly for **side effects** — logging, metrics, snapshots, correlation propagation. Avoid pulling business state through it.\n:::\n\nWhen the wrapped function is an insertion method, the wrapper can inject the\nmatching runtime context — `injectQueryMethodRuntimeContext()`,\n`injectStateMethodRuntimeContext()`, and the siblings for `mutation`,\n`queryParams`, and `asyncProcess` — and call `get` / `set` / `update` /\n`patch` on the owning primitive. That is how registries, WebMCP tools, and\nother advanced patterns seed or replace a query result, a mutation value, a\n`state`, and so on. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n### Example: timing every craft function\n\n```ts\nimport { craftAppConfig, provideFnWrapper, HostTag } from '@craft-ts/core';\n\nprovideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n const start = performance.now();\n try {\n return yield* factory.apply(thisArg, args);\n } finally {\n const name = yield* HostTag();\n console.log(`${name} took ${performance.now() - start}ms`);\n }\n },\n);\n```\n\n## `provideTakeAppSnapshot`\n\n`provideTakeAppSnapshot` captures the list of all **active states** in the app the moment an unexpected error occurs.\n\nThis is one of the most valuable pieces of context you can ship to a log server or AI webhook: you get not just the stack, but the full picture of what the app was holding when it broke.\n\n```ts\nimport { craftAppConfig, provideTakeAppSnapshot } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideTakeAppSnapshot((reports) => {\n // reports: SnapshotReport[]\n // — one entry per active state, with its source, ancestry, and current value\n console.warn('App snapshot:', reports);\n\n // In production you would forward this to a log server or AI webhook:\n // fetch('/api/incident', { method: 'POST', body: JSON.stringify({ reports }) });\n }),\n ],\n});\n```\n\nEach `SnapshotReport` contains:\n\n- `source` — the source tag of the state\n- `from` — the ancestry chain that produced it\n- `state` — the actual current value\n\nUnder the hood, `provideTakeAppSnapshot` registers its own `provideFnWrapper` that triggers the snapshot collection whenever an unexpected error bubbles up. CraftTS control-flow throws such as `CraftGenShortCircuit` and `CraftNotSettled` are deliberately excluded: they are consumed by `catchBlock` and `pendingBlock` boundaries during normal rendering. An unhandled boundary error remains observable and still triggers a snapshot. You do not need to call it manually.\n\n## Craft DOM event hooks\n\nEvery DOM event bound from a Craft template goes through the\n`CRAFT_DOM_EVENT_HOOK` token. Hooks run in the injector of the component that\ndeclared the element, and compose in registration order. A hook must call\n`next()` to preserve the component action.\n\n\n\n\nThe hook receives the native event, its normalized name, the element, the\ncomponent name, and a descriptive `interactionName` such as\n`SavePanel:button:save:click`. This is the extension point for analytics,\nauthorization, tracing, or correlation IDs. A hook can also stop an action by\nnot calling `next()`.\n\n## `provideCorrelationIdTracking`\n\n`provideCorrelationIdTracking` ties every async operation back to the **user gesture** that triggered it.\n\nWhen a Craft template action runs, a fresh correlation id is generated from its\nlocation (`SavePanel:button:save:click:uuid`, for example). Navigation back and\nforward still generate `nav-back:uuid` and `nav-forward:uuid`. Every generator\ninvoked downstream — directly or transitively, sync or async — captures that\nid at invocation time.\n\n```ts\nimport { craftAppConfig, provideCorrelationIdTracking } from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [provideCorrelationIdTracking()],\n});\n```\n\nOnce enabled, the correlation id is attached to the metadata of browser boundaries like `Console`, so a single `yield* Console.error(...)` carries:\n\n- `startCorrelationId` — the id captured when the current generator was invoked\n- `lastCorrelationId` — the most recent id observed in the app\n- `mayCorrelatedIds` — the chain of ids the operation can be linked to\n\nThis lets you reconstruct, from logs alone, the full causal chain between _\"user clicked Save\"_ and _\"the third sub-request returned 500 four seconds later\"_.\n\nCombined with `provideTakeAppSnapshot`, you get on every unexpected error:\n\n- the stack\n- the snapshot of all active states\n- the correlation id chain back to the originating user gesture\n\n## Putting It All Together\n\nWire all three in your `appConfig`:\n\n```ts\nimport {\n craftAppConfig,\n Console,\n provideFnWrapper,\n provideTakeAppSnapshot,\n provideCorrelationIdTracking,\n} from '@craft-ts/core';\n\nexport const appConfig = craftAppConfig({\n // ...\n providers: [\n provideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n try {\n return yield* factory.apply(thisArg, args);\n } catch (error) {\n yield* Console.error(error);\n throw error;\n }\n },\n ),\n provideCorrelationIdTracking(),\n provideTakeAppSnapshot((reports) => {\n // forward to your log server or AI webhook\n console.warn('App snapshot:', reports);\n }),\n ],\n});\n```\n\nYou now have, on any unexpected error: a console error in dev, a full app snapshot, and the correlation chain back to the originating user action — all without a single line of instrumentation inside your business code.\n\n## See Also\n\n- [`craftService`](/guide/app/craft-service)\n- [`Browser Boundaries`](/guide/testing/browser-boundaries) — `Console`, `LocalStorage`, etc., used inside wrappers\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "/guide/advanced/pattern-matching",
|
|
24
|
+
"title": "Pattern matching",
|
|
25
|
+
"body": "# Pattern matching\n\n`craftMatch` is type-safe pattern matching over a **bare literal union** (a `string` / `number` /\n`enum` union). It is the value-level counterpart of the exception-level\n[`catchTag` / `catchTag.exhaustive`](/guide/advanced/program-operators) pair: one call for a single case, a\n`.exhaustive` variant whose handler map is checked to cover the union **at compile time**.\n\n**Use it when** a literal union drives a decision and forgetting a case should\nbe a build error — a status, a role, a mode.\n**Not for** a two-way boolean; a ternary is clearer.\n\nUse it wherever a `switch` would go — mapping a status to a label, an icon, a component input — but\nwith a compiler that refuses to build when you add a member to the union and forget a branch.\n\n## Why not a plain `switch`?\n\nA `switch` (or an object lookup) over a union has three recurring problems:\n\n```ts\ntype Status = 'active' | 'idle' | 'error';\n\nfunction label(status: Status) {\n switch (status) {\n case 'active':\n return 'Running';\n case 'idle':\n return 'Waiting';\n // 'error' forgotten → no error, `label` silently returns undefined\n }\n}\n```\n\n| Concern | `switch` / object lookup | `craftMatch.exhaustive` |\n| --------------------------- | ----------------------------------------- | --------------------------------- |\n| Missing a union member | silent `undefined` at runtime | **compile error** |\n| Handler for a non-member | silent dead code | **compile error** |\n| Value passed to each branch | widened to the whole union | narrowed to its own literal |\n| Return type | union incl. `undefined` unless you assert | exact union of the branch returns |\n\n## Signature\n\n```ts\n// Single case — optional match\ncraftMatch<Value extends string | number, Case extends Value, R>(\n value: Value,\n matchCase: Case,\n handler: (value: Case) => R,\n): R | undefined;\n\n// Exhaustive — every union member needs a handler\ncraftMatch.exhaustive<Value extends string | number, R>(\n value: Value,\n handlers: { [K in Value]: (value: K) => R },\n): R;\n```\n\nExhaustiveness is enforced natively by the mapped handler type `{ [K in Value]: (value: K) => R }` —\nthe union members **are** the required keys, so a missing key or a key outside the union is a plain\ntype error, and each handler receives its own narrowed literal.\n\n## Single case\n\nRuns the handler only when `value` equals `matchCase`, otherwise returns `undefined`:\n\n```ts\nimport { craftMatch } from '@craft-ts/core';\n\nconst status = 'active' as Status;\n\nconst spinner = craftMatch(status, 'active', () => '⏳'); // '⏳'\nconst nope = craftMatch(status, 'error', () => '💥'); // undefined\n```\n\nThe handler's argument is narrowed to the matched literal (`'active'` above), and the result is typed\n`R | undefined`.\n\n## Exhaustive match\n\nThe handler map must cover **exactly** the union — no more, no less:\n\n```ts\nimport { craftMatch } from '@craft-ts/core';\n\nconst label = (status: Status) =>\n craftMatch.exhaustive(status, {\n active: () => 'Running',\n idle: () => 'Waiting',\n error: () => 'Failed',\n });\n```\n\nAdd `'pending'` to `Status` and every `craftMatch.exhaustive` over it stops compiling until you add\nthe branch — the exhaustiveness wall you would otherwise hand-roll with a `never` assertion in a\n`switch`'s `default`.\n\nEach handler is narrowed to its own literal, and the result is the union of the branch return types:\n\n```ts\nconst view = craftMatch.exhaustive(status, {\n active: () => ({ color: 'green', text: 'Running' }),\n idle: () => ({ color: 'gray', text: 'Waiting' }),\n error: () => ({ color: 'red', text: 'Failed' }),\n});\n// view: { color: string; text: string }\n```\n\n### The compile errors it catches\n\n```ts\n// ❌ missing a member — the union is not fully covered\ncraftMatch.exhaustive(status, {\n active: () => 'Running',\n idle: () => 'Waiting',\n}); // Type error: property 'error' is missing\n\n// ❌ a handler for something outside the union\ncraftMatch.exhaustive(status, {\n active: () => 'Running',\n idle: () => 'Waiting',\n error: () => 'Failed',\n unknown: () => 'nope', // Type error: 'unknown' is not in Status\n});\n```\n\n## With enums\n\nA TypeScript `enum` compiles to a `string | number` union, so it works unchanged:\n\n```ts\nenum Tab {\n Overview = 'overview',\n Billing = 'billing',\n Team = 'team',\n}\n\nconst title = (tab: Tab) =>\n craftMatch.exhaustive(tab, {\n [Tab.Overview]: () => 'Overview',\n [Tab.Billing]: () => 'Billing',\n [Tab.Team]: () => 'Team',\n });\n```\n\n## Scope & limits\n\n- **Bare literal unions only.** `craftMatch` matches on the value itself, not on a discriminant\n field — it does not (yet) take a union of objects keyed by a `type` / `kind` field. Map the\n discriminant to a literal first if you need that: `craftMatch.exhaustive(shape.kind, { … })`.\n- **Pure and synchronous.** Unlike [`catchTag`](/guide/advanced/program-operators), `craftMatch` is not a craft\n program: it does not `yield*` and does not track dependencies. To run a different craft program per\n branch, `yield*` inside the handler bodies of a normal generator instead.\n- **No catch-all.** There is the exhaustive form (compile wall) or the single-case form (optional\n `undefined`) — there is no `.otherwise(fallback)`.\n\n## API\n\n| Export | Purpose |\n| ---------------------------------------- | --------------------------------------------------------------------------------- |\n| `craftMatch(value, case, handler)` | Match a single literal; returns `R \\| undefined`. |\n| `craftMatch.exhaustive(value, handlers)` | Match every member; compile-time exhaustive; returns the union of branch results. |\n| `CraftMatchHandlers<Value, R>` | The `{ [K in Value]: (value: K) => R }` handler-map type. |\n\n## See Also\n\n- [Program operators](/guide/advanced/program-operators) — the exception-level counterpart\n- [Exceptions as values](/guide/concepts/exceptions)\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "/guide/advanced/program-operators",
|
|
29
|
+
"title": "Program operators",
|
|
30
|
+
"body": "# Program operators\n\n`.pipe(...)` composes, recovers and retries a [`craftGen`](/guide/concepts/generators)\nprogram with Effect-inspired operators — without leaving the generator model.\n\n**Use it when** a program should handle its own failures locally: retry a flaky\ncall, recover from one specific exception code and carry on.\n**Not when** the failure should reach the route — let it propagate to\n[route exception handling](/guide/routing/exception-handling) instead.\n\n## Import\n\n```typescript\nimport { catchTag, retry } from '@craft-ts/core';\nimport type { CraftProgramOperator, CraftRetryPolicy } from '@craft-ts/core';\n```\n\n## What it does\n\nEvery `craftGen` invocation is a **program**: a `yield*`-composable generator that carries three\ntyped channels:\n\n- **`A`** — the success value returned through `yield*`\n- **`E`** — the union of `craftException` codes it may short-circuit with (type-level only)\n- **dependencies** — the craft service yields relayed to the surrounding driver\n\nInvocations now expose `.pipe(...)`, which applies **program operators** left-to-right:\n\n```typescript\nconst report =\n yield *\n loadSlowReport().pipe(\n catchTag('REPORT_EMPTY', function* () {\n return { generatedAt: 'n/a', totalUsers: 0 };\n }),\n retry({ times: 2, backoff: 'exponential', delayMs: 200 }),\n );\n```\n\nExisting code is untouched: `yield* myProgram(args)` without `.pipe` works exactly as before.\n\n## Why it matters\n\nBefore operators, the **only** place a program's exception could be handled was the route\nboundary (`handleExceptions`). Every reachable code forced a route-level handler, even when the\nright answer was local (\"fall back to an empty report\", \"retry the flaky call\").\n\nWith `.pipe`:\n\n- `catchTag` recovers a code **where the fallback is known** — the code leaves `E`, so the route\n no longer requires a handler for it\n- `retry` re-executes the whole upstream chain on failure\n- everything stays typed: `E` shrinks/grows through each operator, and route exhaustiveness keeps\n checking the **remaining** codes\n\n## `catchTag(code, handler)`\n\nCatches one exception code. The handler is a generator: its yields (craft services, nested\nprograms, `craftUntilSettled`) are relayed to the driver, so its dependencies stay tracked.\n\n```typescript\nconst loadSlowReport = craftGen(function* () {\n const reportRef = yield* SlowReport();\n const report = yield* craftUntilSettled(reportRef);\n return report.totalUsers === 0\n ? craftException({ _tag: 'REPORT_EMPTY' })\n : report;\n});\n\n// In a route resolve: REPORT_EMPTY is recovered locally, so it is REMOVED from\n// the route's exception union — `handleExceptions` does not need (and must not\n// declare) a handler for it.\nresolve: craftResolve(function* () {\n return yield* loadSlowReport().pipe(\n catchTag('REPORT_EMPTY', function* () {\n return { generatedAt: 'n/a', totalUsers: 0 };\n }),\n );\n}),\n```\n\nType effects:\n\n- `E' = E \\ code` plus whatever the handler itself may produce\n- `A' = A | handler success value`\n- the handler receives the caught exception (`code` + `payload`)\n\nA handler can also **re-enter** the exception channel by returning a `craftException` (the new\ncode is added to `E`):\n\n```typescript\ncatchTag('HTTP_TIMEOUT', function* (exception) {\n const flags = yield* FeatureFlags();\n return flags.offlineMode()\n ? cachedFallback\n : craftException({ _tag: 'SERVICE_UNAVAILABLE' });\n});\n```\n\n## `catchTag.exhaustive(handlerMap)`\n\nCatches **every** reachable code through a map that must cover the program's exception union\nexactly — a missing code or a handler for an unreachable code is a **compile error** at the\n`.pipe` application site. Afterwards `E = never`.\n\n```typescript\nconst user =\n yield *\n loadUser(userId).pipe(\n catchTag.exhaustive({\n NOT_FOUND: function* () {\n return GUEST_USER;\n },\n FORBIDDEN: function* () {\n const audit = yield* Audit();\n audit.report('forbidden-user-access');\n return GUEST_USER;\n },\n }),\n );\n// `user` can no longer fail: E = never\n```\n\n```typescript\n// ⛔ compile error — missing handler for 'FORBIDDEN'\nloadUser(userId).pipe(\n catchTag.exhaustive({\n NOT_FOUND: function* () {\n return GUEST_USER;\n },\n }),\n);\n\n// ⛔ compile error — 'TEAPOT' is not a reachable code\nloadUser(userId).pipe(\n catchTag.exhaustive({\n NOT_FOUND: function* () {\n return GUEST_USER;\n },\n FORBIDDEN: function* () {\n return GUEST_USER;\n },\n TEAPOT: function* () {\n return GUEST_USER;\n },\n }),\n);\n```\n\n::: tip Where the check happens\nThe exception union is only known once the operator is applied to a program, so exhaustiveness is\nverified at the `.pipe` call site — not when `catchTag.exhaustive({...})` is built. The handler\nparameter is typed `AnyCraftException & { _tag }` (payload `unknown`): narrow the payload yourself\nif you need it.\n:::\n\n## `retry(policy)`\n\nRe-executes the program when it fails with a matched `craftException`, up to `times` extra\nattempts, then rethrows. Each attempt **replays the whole upstream `.pipe` chain** from the source\ninvocation.\n\n```typescript\ncanActivate: function* () {\n return yield* slowAccessGuard().pipe(\n retry({ times: 2, backoff: 'linear', delayMs: 250 }),\n );\n},\n```\n\n```typescript\nexport type CraftRetryPolicy = {\n times: number; // max RE-executions after the initial attempt\n while?: string[]; // only retry these codes (all when omitted)\n backoff?: 'none' | 'linear' | 'exponential';\n delayMs?: number; // 'none' → flat, 'linear' → delayMs * attempt,\n // 'exponential' → delayMs * 2^(attempt-1)\n};\n```\n\nNotes:\n\n- `E` and `A` are unchanged — retry may exhaust and rethrow the same exception\n- a non-zero `delayMs` suspends between attempts (an internal await-request), so it needs an\n async driver: a route chain or a primitive loader. In a purely synchronous context the usual\n await-not-supported behaviour of that driver applies\n- only re-invocable programs can be retried (a `craftGen` invocation or a `.pipe` stage); passing\n a hand-built bare generator raises an explicit error on the first needed retry\n\n## Programs inside `query` / `mutation` / `asyncProcess` loaders\n\nThe three primitives drive their generator loaders with the same async pump as route guards, so\nloaders can suspend on `craftUntilSettled` and compose programs:\n\n```typescript\nconst { userQuery } = query('userQuery', {\n params: () => userId(),\n loader: function* ({ params }) {\n const api = yield* UserApi();\n return yield* loadUser(params).pipe(\n retry({ times: 3, backoff: 'exponential', delayMs: 200 }),\n );\n },\n});\n```\n\nAn **uncaught** program exception does not crash the loader: it feeds the primitive's exception\nchannel —\n\n```typescript\nqueryRef.status(); // 'exception'\nqueryRef.hasException(); // true\nqueryRef.exception()?._tag; // e.g. 'USER_NOT_FOUND'\nqueryRef.exception()?.payload; // the exception payload\n```\n\n— and the loader's reachable program exceptions are folded into the primitive's typed\n`exception()` union.\n\n## Custom operators\n\nAn operator is just a function from one program generator to another. Type yours with\n`CraftProgramOperator`:\n\n```typescript\nimport type { CraftProgramOperator } from '@craft-ts/core';\n\n// Measures the program duration; passes everything else through unchanged.\nconst timed =\n (label: string): CraftProgramOperator<unknown, unknown, unknown, unknown> =>\n (program) =>\n (function* () {\n const start = performance.now();\n try {\n return yield* program;\n } finally {\n console.debug(`${label}: ${performance.now() - start}ms`);\n }\n })();\n```\n\nGuidelines:\n\n- always consume the received program with `yield*` (a generator that is never driven does\n nothing)\n- relay foreign yields untouched so dependency tracking keeps working\n- let `CraftGenShortCircuit` propagate unless handling exceptions is the operator's purpose\n\n## How it behaves\n\n- Operators are plain generator wrappers: no driver knows about them, and `E` travels only at the\n type level (the runtime signal is a thrown `CraftGenShortCircuit`)\n- `.pipe` folds left-to-right; each stage is itself a program, so operators after it can replay it\n (that is how `retry` after `catchTag` re-runs the recovery too)\n- `catchTag.exhaustive` rethrows a code outside its typed map (runtime safety net for exceptions\n that escaped the types)\n\n## See Also\n\n- [`craftGen`](/guide/concepts/generators) — building programs\n- [`Route Guards`](/guide/routing/guards) — where guard programs run\n- [`Exception Handling`](/guide/concepts/exceptions) — the route-boundary\n counterpart (`handleExceptions`)\n- [`query`](/guide/state/server-state) — loaders as programs\n"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "/guide/advanced/ssr-hydration",
|
|
34
|
+
"title": "SSR and hydration",
|
|
35
|
+
"body": "# SSR and hydration\n\nCraft can render a complete application to deterministic HTML on the server,\ntransfer its serializable state, and then attach the browser runtime to the\nexisting DOM. This runtime path does not require the Craft compiler.\n\n**Use it when** the first response must contain useful HTML without rebuilding\nthe same component tree during browser startup.\n\n## Render one isolated request\n\n`renderCraft` creates a new platform, injector, primitive registry, in-memory\nhistory, and storage pair for every call. Do not reuse its injector between\nrequests.\n\n```ts\nimport { renderCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst controller = new AbortController();\nconst rendered = await renderCraft({\n config: appConfig,\n url: '/dashboard?page=2',\n signal: controller.signal,\n timeoutMs: 5_000,\n});\n\nreturn new Response(\n `<!doctype html><html><body>${rendered.html}</body></html>`,\n {\n headers: { 'content-type': 'text/html; charset=utf-8' },\n },\n);\n```\n\nThe result separates `rootHtml`, collected `styles`, and the transfer\n`snapshot`; `html` combines all three. `renderToString(component, options)` is\nthe smaller API when no application config is needed.\n\nAll app initializers are run before the server render. Aborting the request\nrejects pending SSR work with the signal reason. A blocking render that exceeds\n`timeoutMs` rejects with `CraftSsrTimeoutError` and lists the pending sources.\nA route policy may set a shorter `timeoutMs` for the sources it blocks.\n\n## Hydrate the existing DOM\n\nServe the generated `<craft-root>`, style element, and transfer script without\nchanging them. The browser entry point then uses the same app config. `startCraft`\nchooses hydration when the SSR marker is present and falls back to a normal\nclient mount when the page was not rendered by Craft SSR:\n\n```ts\nimport { startCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nconst app = startCraft({ config: appConfig });\n```\n\nHydration restores the snapshot before creating primitives, claims elements,\ntext markers, and block boundaries by their structural keys, and attaches\nbindings and listeners. A resolved transferred query therefore does not issue\nthe same initial request again. The transfer script and server style element\nare removed after a successful first pass; the normal client style registry\nthen owns the styles.\n\nCall `app.destroy()` when the application host is removed. Pass `host`,\n`snapshot`, or `onMismatch` when the defaults are not appropriate.\n\nUse `hydrateCraft` directly when the application needs to force hydration or\npass hydration-specific options.\n\n## Choose what SSR does with pending data\n\nThe boundary that owns the pending UI owns its SSR policy. A query still only\ndescribes data and its loader.\n\n```ts\ndiv(UserList()).pipe(\n pendingBlock({\n ssr: 'block',\n fallback: () => UserListSkeleton(),\n }),\n);\n```\n\nThe three modes are:\n\n| Mode | Server action | Initial HTML |\n| ---------- | -------------------------------------- | ---------------------------------------- |\n| `block` | Starts and awaits the suspended source | Resolved content and query snapshot |\n| `fallback` | Does not await the source | Boundary fallback |\n| `client` | Does not start the source | Explicit browser-owned shell or fallback |\n\n`client` requires an explicit fallback in the catch-all form. An exhaustive\nboundary already supplies explicit source fallbacks.\n\nA route can provide the page default:\n\n```ts\ncraftRoute('dashboard', {\n path: 'dashboard',\n loadComponent: () => import('./dashboard'),\n ssr: { mode: 'block' },\n});\n```\n\nThe nearest local `pendingBlock` wins over the route policy. A read that\nsuspends without either policy fails with\n`CraftUnhandledSsrResolutionError`; Craft never silently skips the loader or\nwaits forever. Reloading queries keep rendering their previous value and do\nnot suspend.\n\n## Structural identity and local recovery\n\nHydration keys come from the component and template path, not from a global\ncounter or random id. Static children use their logical position, blocks use a\nstable boundary segment, and `each` entries use the declared business key.\nServer and client must therefore execute the same template structure and use\nstable `each` keys.\n\nIf a key is absent, a tag differs, text changed, or a dynamic branch no longer\nmatches, Craft recreates that local subtree and keeps compatible siblings. In\ndevelopment it also reports a `HydrationMismatchError` containing the key,\nexpected node, actual node, and reason.\n\n## Transfer snapshot rules\n\nOnly values composed of JSON primitives, plain objects, and arrays are\ntransferred. Functions, `bigint`, class instances, and cycles fail explicitly.\nUnreadable or absent primitive values are omitted. Query entries include their\nstatus, resolved value when present, and a plain `{ name, message }` error when\nthe runtime exposes one.\n\n`serializeCraftTransferSnapshot` escapes `<`, `>`, `&`, U+2028, and U+2029, so\nthe JSON cannot close its `application/json` script element. Treat the snapshot\nas application data nevertheless: do not place secrets in state that reaches\nthe browser.\n\n## Current scope\n\nThis first runtime release covers full-page hydration, deterministic HTML,\nCSS collection, state/query transfer, async boundary policies, keyed `each`\nrecovery, and local mismatch remounts. Streaming, resumability, islands,\ncross-boundary event replay, compiler-generated renderers, and a direct\nserver-function transport are later work.\n"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "/guide/advanced/temporal-runtime",
|
|
39
|
+
"title": "Temporal runtime",
|
|
40
|
+
"body": "# Temporal runtime\n\nCraft treats time as a runtime capability rather than as a direct call to the\nbrowser or Node timer APIs. This gives asynchronous programs one temporal\nseam that can be replaced in tests, inspected during diagnostics, and cleaned\nup with the lifetime that created it.\n\n**Use it when** a Craft program needs a delay, timeout, retry backoff, polling\nor another cancellable time-based operation.\n**Do not use it** for civil dates such as timestamps stored in a database:\nthose are dates, not elapsed-time measurements.\n\n## Import\n\n```typescript\nimport {\n CRAFT_TEMPORAL_RUNTIME,\n craftSleep,\n exponentialTemporalSchedule,\n fixedTemporalSchedule,\n provideCraftTemporalRuntime,\n VirtualCraftTemporalRuntime,\n withCraftTimeout,\n} from '@craft-ts/core';\n```\n\n## The temporal model\n\nThe runtime separates three responsibilities:\n\n- **clock** — reads monotonic time for durations and civil time for dates;\n- **task** — schedules one cancellable callback or sleep operation;\n- **schedule** — decides whether an operation continues and how long the next\n wait should be.\n\n```text\nCraft program\n ├── waits → craftSleep(...)\n ├── times out → withCraftTimeout(...)\n └── retries → a temporal schedule\n │\n ▼\nCRAFT_TEMPORAL_RUNTIME\n ├── RealCraftTemporalRuntime\n └── VirtualCraftTemporalRuntime\n```\n\n`setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` are runtime\nimplementation details. A polling loop should normally be expressed as a\nsequence of operations and a schedule so it can stop when its owner is\ndestroyed.\n\n## Waiting in a Craft program\n\n`craftSleep` is a yieldable delay. It does not create a native timer when the\ngenerator is created. The asynchronous Craft driver receives the request and\ndelegates it to the configured temporal runtime.\n\n```typescript\nimport { craftGen, craftSleep } from '@craft-ts/core';\n\nconst refreshAfterDelay = craftGen(function* () {\n yield* craftSleep(500, { owner: 'refresh' });\n return 'refresh now';\n});\n```\n\nThe delay can be used in route guards and in asynchronous primitive loaders,\nwhich already use the asynchronous program driver:\n\n```typescript\nconst data = query('data', {\n loader: function* () {\n yield* craftSleep(100);\n return loadData();\n },\n});\n```\n\nWhen a query is retriggered, its loader abort signal is propagated to pending\ntemporal awaits. A stale `craftSleep` is therefore cancelled and its generator\ndoes not resume. The resource still protects the latest result if an operation\nhas already passed its sleep or does not observe the signal.\n\nMutation loaders intentionally keep their already-started temporal operation\nvalid when a new mutation is triggered. The previous resource result can still\nbe ignored as stale, but the mutation's generator is not interrupted.\n\nSynchronous drivers such as `craftUse` cannot suspend on `craftSleep`. They\nfail with an explicit async-driver error instead of silently creating an\nuntracked Promise.\n\n## Replacing the runtime in tests\n\n`VirtualCraftTemporalRuntime` never waits for wall-clock time. It starts at\nzero by default, orders equal deadlines by creation order, and exposes the\npending tasks for assertions.\n\n```typescript\nimport { ɵInjector as Injector } from '@craft-ts/core';\nimport {\n executeGeneratorCompatibleFactoryAsync,\n provideCraftTemporalRuntime,\n VirtualCraftTemporalRuntime,\n} from '@craft-ts/core';\n\nconst clock = new VirtualCraftTemporalRuntime();\nconst injector = Injector.create({\n providers: [provideCraftTemporalRuntime(clock)],\n});\n\nconst result = executeGeneratorCompatibleFactoryAsync({\n factory: function* () {\n yield* craftSleep(100);\n return 'done';\n },\n thisArg: undefined,\n getInjector: () => injector,\n args: [],\n invalidYieldErrorMessage: 'Invalid Craft yield.',\n});\n\nawait clock.advanceBy(99);\n// The program is still suspended.\n\nawait clock.advanceBy(1);\nawait expect(result).resolves.toMatchObject({\n kind: 'done',\n value: 'done',\n});\n```\n\nThe main test operations are:\n\n```typescript\nawait clock.advanceBy(250); // move time forward\nawait clock.advanceTo(1_000); // move to an exact time\nawait clock.advanceToNextTask(); // execute the nearest task\nawait clock.runUntilIdle(); // execute until no task remains\nclock.pendingTasks(); // inspect all pending tasks\nclock.pendingTasks('refresh'); // inspect one owner\nclock.reset(); // cancel tasks and restore the clock\n```\n\nTasks with the same deadline run in creation order. A task created while an\nexpired task is running is then considered at the same virtual time and is\nalso executed before the clock advances beyond that deadline.\n\n## Timeouts\n\n`withCraftTimeout` races an operation against the configured runtime. If the\noperation wins, the timeout task is cancelled. If the deadline wins, the\nPromise rejects with `CraftTimeoutError`.\n\n```typescript\nconst response = await withCraftTimeout(\n fetch('/api/report').then((response) => response.json()),\n 5_000,\n { owner: 'report-loader' },\n);\n```\n\nThe timeout controls the Craft operation's result. It does not automatically\ncancel an external HTTP request. Pass an `AbortSignal` to the underlying API\nwhen the resource itself must be interrupted as well.\n\n## Schedules\n\nA schedule is a pure policy. It does not own a timer and it does not run an\ninterval. It receives the next attempt number and returns either a delay or a\nstop decision.\n\n```typescript\nconst backoff = exponentialTemporalSchedule(100, {\n factor: 2,\n maxAttempts: 4,\n maxDelayMs: 2_000,\n});\n\nbackoff.next({ attempt: 1, elapsedMs: 0 }); // { done: false, delayMs: 100 }\nbackoff.next({ attempt: 2, elapsedMs: 100 }); // { done: false, delayMs: 200 }\n```\n\nAvailable policies include:\n\n```typescript\nfixedTemporalSchedule(500, { maxAttempts: 3 });\nexponentialTemporalSchedule(100, { factor: 2 });\nsequenceTemporalSchedule([100, 250, 1_000]);\n```\n\n`retry` uses the temporal runtime for non-zero backoff delays and accepts a\ncustom schedule when the built-in policies are not enough:\n\n```typescript\nconst user =\n yield *\n loadUser().pipe(\n retry({\n times: 3,\n schedule: exponentialTemporalSchedule(200, {\n factor: 2,\n maxDelayMs: 2_000,\n }),\n }),\n );\n```\n\nPrefer a schedule over `setInterval` for polling. The operation completes one\nstep, the schedule decides whether another step is needed, and the next task\nis created only after the current step has finished. This avoids accidental\noverlap and makes destruction cancellable.\n\n## Ownership and cleanup\n\nEvery task can carry an `owner` label for inspection. Runtime integrations that\nhave a `DestroyRef` attach the task to that lifetime:\n\n```typescript\nconst task = runtime.schedule(refresh, 1_000, {\n kind: 'polling',\n owner: 'user-list',\n destroyRef,\n});\n\ntask.cancel(); // idempotent; returns whether cancellation happened\n```\n\nDestroying the owner cancels its pending tasks. A suspended `craftSleep` is\nrejected with `TemporalCancelledError`, so a destroyed program cannot resume\nand mutate state after its lifetime has ended.\n\n## Choosing the right abstraction\n\n| Need | Use |\n| -------------------------------- | ------------------------------------------- |\n| Wait once inside a generator | `craftSleep` |\n| Bound an operation by a deadline | `withCraftTimeout` |\n| Retry after an error | `retry` with a schedule |\n| Repeat work without overlap | one operation plus a schedule |\n| Store a timestamp | a civil date value, not the monotonic clock |\n| Test time-dependent behavior | `VirtualCraftTemporalRuntime` |\n\n## What not to do\n\nAvoid timer Promises created directly inside Craft programs:\n\n```typescript\n// Avoid\nyield * new Promise((resolve) => setTimeout(resolve, 500));\n```\n\nUse the temporal request instead:\n\n```typescript\n// Prefer\nyield * craftSleep(500);\n```\n\nDirect timer globals are reported by the `no-direct-temporal-globals` dev-tools\nrule. The temporal runtime implementation itself is the explicit exception.\n\n## Limitations\n\n- Browser background-tab throttling is not simulated by the virtual runtime.\n- Microtasks and macrotasks remain distinct; advancing virtual time flushes the\n microtasks caused by the tasks it executes.\n- RxJS schedulers are not automatically replaced by the Craft runtime.\n- A timeout does not cancel an external resource unless that resource accepts\n and observes an abort signal.\n- Timers created by third-party APIs remain outside Craft ownership.\n\n## See also\n\n- [`retry`](/guide/advanced/program-operators#retrypolicy)\n- [`asyncProcess`](/guide/state/async-process)\n- [Testing services](/guide/testing/services)\n- [Generators and `yield*`](/guide/concepts/generators)\n"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "/guide/ai/dev-page",
|
|
44
|
+
"title": "Live page MCP",
|
|
45
|
+
"body": "# Live page MCP\n\nThe running development tab publishes its named controls. A coding agent fills,\nclicks, and inspects **that** page — no second browser, no DOM reverse-engineering.\n\n**Use it when** a Cursor agent must drive or inspect the `ng serve` tab you\nalready have open.\n**Not when** you are writing Craft away from a running app — use\n[`@craft-ts/mcp`](/resources/ai-agents) for docs and skills. **Not when** you\nwant to mutate a primitive without the UI — use the `registry.*` tools on the\nsame local MCP.\n\n## Connect the local MCP\n\nThe tool lives on `@craft-ts/function-registry-mcp`, not on the published\n`@craft-ts/mcp` docs server. From the craft-ts repo:\n\n```sh\nnpm run registry:mcp\n```\n\nPoint Cursor at that stdio server. It already listens on `ws://127.0.0.1:3333`\nfor the demo tab. Each tab keeps a stable `clientId` in `sessionStorage`.\n\n## One ready tab\n\nEach tab has a `clientId` in `sessionStorage`. Duplicating a tab copies it; the\nbroker assigns a new id (`hello/ok`) so the two tabs do not fight.\n\nOmit `clientId` when **exactly one tab is `ready`**. A ghost `reloading` card\n(HMR, F5) does not count. Two `ready` tabs → pass `clientId` from\n`registry.clients` (id, status, url). Never pick “latest”. The error is\n`Multiple ready page clients; clientId is required. Available clients: <id> ready <url>, <id> ready <url>`.\nZero ready with several ghosts is\n`No ready page client. Reloading: <id> (last url <url>), <id> (last url <url>)`.\nZero cards is `page client is not connected`.\n\nClosing the tab sends `page/goodbye`; the card is dropped. Opening a new tab is\na new id. If `page client \"<id>\" is not connected`, call `registry.clients` and\nretry without id when a single ready remains.\n\nClosing without goodbye (crash) looks like reload for up to 20s.\n\n## One tool: `page`\n\nOmit `act` to read the current surface. The broker **always asks the live tab**\n— a Craft `value:` that changed without a DOM mutation is still current. Pass\n`act` to run a batch, then receive the **new** state in the same round-trip.\n\nDefault `detail` is `\"controls\"`: the named interactive surface (id, role,\naccessible name, value, enabled, index, and `track` when the node is inside\n`each`). Pass `detail: \"dom-styles\"` only to debug layout or CSS — it is large\nand opt-in.\n\n`id` is the literal local name from the helper:\n\n\n\nThat name is unique in the app graph\n(`assertInteractiveElementNamed`). The renderer writes `data-craft-name=\"save\"`.\nDo not prefix it with the component name. When `each` repeats the same id, pass\n`match.index` or `match.track`.\n\n## Fill, click, goto, ready\n\n`act: [{ \"goto\": \"/login-form\" }]` navigates in the tab (Craft router).\nThe WebSocket stays up. Prefer `goto` over clicking `navLink` — every nav item\nshares that id. Paths like `/login-form` and full URLs both work.\n\nA `fill` sets the control and dispatches one `input` or `change` (then blur), so\n`CraftFieldDirective` validation and touched state run. A click is `act` with\nonly `id`. The batch runs in order and stops on the first error.\n\nWhile `ng serve` rebuilds, the socket drops but the broker **keeps** the client\ncard. `page` waits until the tab is `ready` again (up to `timeoutMs`, default\n20s). You do not poll.\n\n## See also\n\n- [Coding agents](/resources/ai-agents) — which MCP to use for docs vs the live tab\n- [Architecture rules](/guide/testing/architecture) — unique interactive names\n- [Observability](/guide/advanced/observability) — primitive traces, not DOM\n"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "/guide/app/abstract-services",
|
|
49
|
+
"title": "Abstract services",
|
|
50
|
+
"body": "# Abstract services\n\nAn `abstract` service declares a **contract** with no implementation, and forces\na concrete one to be supplied downstream. This is what makes a service's\nimplementation a decision of the mounting site — a route, a feature config, a\ntest — instead of a hard import.\n\n## Abstract Requirements\n\nUse `scope: 'abstract'` to declare a contract that must be implemented elsewhere.\n\n\n\n\nConcrete services can then depend on `CounterRequirement`.\n\n## Abstract Providers\n\nAn `abstract` service also exposes a `provideX(factory)` helper. It takes a **factory** — a plain\nfunction or a generator — produces a value matching the contract, and binds it to the requirement\ntoken. This lets you implement the contract **inline at the providing site** (a route, a component,\na feature config) instead of declaring a separate concrete `craftService`.\n\n```typescript\nimport { abstract, craftService } from '@craft-ts/core';\n\ntype User = { name: string };\n\nconst { User, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// Implement the contract inline:\nconst providers = [provideUser(() => ({ name: 'Ada' }))];\n\n// Anywhere downstream, inside a craft generator:\nconst user = yield * User();\n```\n\nThe factory can be a **generator** that yields other services. Everything it yields is tracked, so\nthe resulting provider participates in the cascade DI check just like a regular service:\n\n```typescript\nconst { Greeting } = craftService(\n { name: 'Greeting', providedIn: 'global' },\n () => ({ prefix: 'Hello' }),\n);\n\nconst providers = [\n provideUser(function* () {\n const greeting = yield* Greeting();\n return { name: `${greeting.prefix} Ada` };\n }),\n];\n```\n\nThis is the foundation of route-scoped providers: a route can implement an abstract contract from\nits own guarded data / params. See\n[Type-safe DI/Routes → Route Providers](/guide/routing/route-providers).\n\n## See Also\n\n- [Service scopes](/guide/app/service-scopes)\n- [Route providers](/guide/routing/route-providers)\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "/guide/app/app-start",
|
|
54
|
+
"title": "App start",
|
|
55
|
+
"body": "# App start\n\n`onAppStart` declares work that must run — and finish — before the application\nrenders, owned by the service that needs it rather than by a global bootstrap\nfile.\n\n**Use it when** something must be true before the first paint: a loaded config,\na restored session, a feature-flag fetch.\n**Not when** the work can happen after render — that is just an effect, and\nblocking on it costs your users a blank screen.\n\n## Import\n\n```typescript\nimport { onAppStart } from '@craft-ts/core';\n```\n\n## Overview\n\n`onAppStart(...)` is used inside a `craftService(..., function* () {})` generator to declare logic that should run when the application starts.\n\nImportant constraints:\n\n- the owning service must be declared with `appStart: true`\n- a service can declare `yield* onAppStart(...)` only once\n- the callback can be a plain function or a generator function\n- nested `onAppStart(...)` calls inside the callback are not supported\n\n`craftAppConfig(...)` runs registered app-start services during application initialization.\n\n## Signature\n\n```typescript\nfunction onAppStart(\n run: () => Observable<unknown> | Promise<unknown> | void,\n): Generator<unknown, void, unknown>;\n\nfunction onAppStart<Yielded>(\n run: () => Generator<\n Yielded,\n Observable<unknown> | Promise<unknown> | void,\n unknown\n >,\n): Generator<unknown, void, unknown>;\n```\n\n## Plain Callback\n\nUse a plain callback when startup logic does not need to `yield*` crafted dependencies.\n\n\n\n\n## Generator Callback\n\nUse a generator callback when startup logic needs to `yield*` crafted dependencies.\n\n```typescript\nimport { Console, craftService, onAppStart } from '@craft-ts/core';\n\nexport const { AppStartLog } = craftService(\n {\n name: 'AppStartLog',\n providedIn: 'toProvide',\n appStart: true,\n },\n function* () {\n yield* onAppStart(function* () {\n yield* Console.log('This is a log from the appStart callback');\n return new Promise((resolve) => setTimeout(resolve, 1000));\n });\n\n return 1;\n },\n);\n```\n\n\n\nThe callback generator supports the same dependency-yield semantics as a normal crafted generator for:\n\n- `yield* X(...)`\n- `yield*` exposure tokens returned by derivation callbacks\n- browser boundaries such as `yield* Console.log(...)`\n\nDependencies used only inside this callback are merged into the parent service dependency graph.\n\n## Registering it with `craftAppConfig`\n\nDeclaring `onAppStart` is only half of it — nothing runs until the service is\n**registered**. Two steps, and both are mechanical.\n\nAugment the app-start registry so the service is known by name:\n\n```typescript\ndeclare module '@craft-ts/core' {\n interface CraftAppStartRegistry {\n AppStartLog: typeof AppStartLog;\n }\n}\n```\n\nThen list it in `craftAppConfig`:\n\n```typescript\nexport const appConfig = craftAppConfig({\n appStart: {\n AppStartLog,\n },\n providers: [\n /* … */\n ],\n});\n```\n\n`craftAppConfig` runs every registered app-start service during the application's\napplication initialization, and the app renders once they have settled.\n\nHere it is end to end:\n\n\n\n\n::: tip The registry augmentation is generated\nThe `declare module` block is written for you by the craft-ts ESLint plugin —\nyou rarely type it by hand.\n:::\n\n::: warning A declared hook that is never registered simply never runs\nIt is not an error: `appStart: true` and `yield* onAppStart(...)` describe the\nservice, the `appStart` map in `craftAppConfig` is what activates it. If startup\nlogic silently doesn't happen, check the map first.\n:::\n\n## Dependency Tracking\n\nGenerator callbacks are type-visible.\n\nIf the callback only uses `Console`, the owning service dependency graph includes `ConsoleService` as a normal dependency node, with `browserBoundary: true`.\n\nThis means startup-only dependencies are still visible to:\n\n- `GetServiceDependencies<typeof X>`\n- route/app DI checks built on top of service metadata\n- test helpers that inspect crafted dependency graphs\n\n## Runtime Behavior\n\n`onAppStart(...)` does not run when the service instance is created.\n\nIt registers a startup hook that is executed when the application initializer runs that service, typically through `craftAppConfig(...)`.\n\nIf the callback returns:\n\n- `void`: startup continues immediately\n- `Promise`: startup waits for the promise to resolve\n- `Observable`: startup waits until the observable completes\n\nGenerator callbacks preserve the same waiting behavior. The generator itself resolves first, then its returned `Promise` / `Observable` / `void` is used as the startup result.\n\n## Common Errors\n\n### Missing `appStart: true`\n\n```typescript\nyield * onAppStart(() => undefined);\n```\n\nThis throws at runtime if the owning service was not declared with `appStart: true`.\n\n### Nested `onAppStart(...)`\n\n```typescript\nyield *\n onAppStart(function* () {\n yield* onAppStart(() => undefined); // unsupported\n return undefined;\n });\n```\n\nNested declarations are rejected at runtime.\n\n## See Also\n\n- [`craftService`](/guide/app/craft-service)\n- [`Browser Boundaries`](/guide/testing/browser-boundaries)\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "/guide/app/craft-service",
|
|
59
|
+
"title": "craftService",
|
|
60
|
+
"body": "# craftService\n\nA service is a factory with a **name** and a **scope** — not a class. It packages\nprimitives and dependencies behind an explicit API, and keeps the whole\ndependency graph visible to the compiler.\n\n**Use it when** logic outgrows a single component field, or when two places need\nthe same behaviour.\nUse a small adapter when a dependency is owned by the runtime environment\nrather than by your application.\n\nThe contrast with `inject(...)` scattered across classes is the point:\ndependencies here are explicit and **type-visible**, which is what the route DI\ncheck and the test registers read.\n\n```typescript\nimport { craftService } from '@craft-ts/core';\n```\n\nService inputs that can change should be consumed as yieldable readers\n(`CraftServiceInput<T>`), the service counterpart of a component `Input<T>`.\nYield them so the input-to-service edge stays in the dependency graph:\n\n```typescript\nimport { craftService, query, type CraftServiceInput } from '@craft-ts/core';\n\nconst { UserQuery } = craftService(\n { name: 'UserQuery', providedIn: 'global' },\n (inputs: { userId: CraftServiceInput<string | undefined> }) =>\n query('userQuery', {\n params: function* () {\n return yield* inputs.userId();\n },\n loader: ({ params }) => ApiService.getItemById(params),\n }),\n);\n```\n\nThe call site still accepts a resolved value, a signal, or a Craft\nreader — the service boundary adapts it into that reader. Inside the factory,\nalways `yield* inputs.x()`.\n\n## What you get\n\nDeclaring a service gives you a set of generated helpers. For one named\n`Counter`:\n\n- `Counter(...)` — consume or compose it inside a craft generator\n- `Counter.someProperty(...)` — derive one public property directly\n- `provideCounter(...)` — for provider-capable scopes\n- `COUNTER_META_DATA` — for metadata-driven tooling\n- `CounterRequirement` — for `abstract` services\n- `provideCounter(factory)` — on `abstract` services, to implement the contract\n inline\n\nWhich of those exist depends on the scope.\n\n::: warning Breaking change — no more `injectX`\nThe generated helper is the service name itself: `X`. `craftService` no longer\nexports `injectX`, and the former `XToYield` helper is gone. Use `X()` in a craft\ngenerator and compose with `yield* X()`.\n:::\n\n## Supported scopes\n\nA service declares how many instances of it exist through `scope`:\n`function`, `toProvide`, `global`, `manuallyProvidedAtRoot` or `abstract`.\nDefault to `function`.\n\nEach scope and when to pick it: **[Service scopes](/guide/app/service-scopes)**.\n\n## The common case\n\n\n\n\n## Returning one primitive directly\n\nWhen a service exposes only one primitive, the factory can return its generator\ndirectly. `craftService` drives it and the generated service helper returns the\nprimitive reference:\n\n```typescript\nimport {\n craftService,\n query,\n type CraftServiceInput,\n} from '@craft-ts/core';\n\nconst { UserQuery } = craftService(\n { name: 'UserQuery', providedIn: 'global' },\n (inputs: { userId: CraftServiceInput<string | undefined> }) =>\n query('userQuery', {\n params: function* () {\n return yield* inputs.userId();\n },\n loader: ({ params }) => ApiService.getItemById(params),\n }),\n);\n```\n\nFor several primitives, use `craftYieldRecord`. It resolves every generator in\nthe record and preserves the record keys:\n\n```typescript\nimport {\n craftService,\n craftYieldRecord,\n query,\n state,\n type CraftServiceInput,\n} from '@craft-ts/core';\n\nconst { UserQuery } = craftService(\n { name: 'UserQueryWithState', providedIn: 'global' },\n (inputs: { userId: CraftServiceInput<string | undefined> }) =>\n craftYieldRecord({\n userQuery: query('userQuery', {\n params: function* () {\n return yield* inputs.userId();\n },\n loader: ({ params }) => ApiService.getItemById(params),\n }),\n refresh: state('refresh', 0, ({ update }) => ({\n increment: () => update((value) => value + 1),\n })),\n }),\n);\n```\n\nInside a generator factory, the equivalent explicit form remains available:\n`const userQuery = yield* query(...)`.\n\n## Scoping providers to the service\n\nUse `providers` in the service config when the service factory itself needs locally-scoped dependencies:\n\n```typescript\nconst { UserFacade } = craftService(\n {\n name: 'UserFacade',\n providedIn: 'global',\n providers: [provideUserApi(), provideUserLogger()],\n },\n function* () {\n const api = yield* UserApi();\n const logger = yield* UserLogger();\n\n return {\n rename: (user: { id: string; name: string }, name: string) => {\n logger.log(`rename:${user.id}`);\n return api.updateUser({ ...user, name });\n },\n };\n },\n);\n```\n\nThis is separate from `provideUserFacade()`, which is only generated for provider-capable scopes like `toProvide`.\n\n## Composing services\n\n\n\n\n## Shaping the public API\n\n`yield* X()` can expose only part of a dependency, and `X.property()` derives a\nsingle one. See **[Shaping a service's public API](/guide/app/expose-api)**.\n\n## Contracts without an implementation\n\n`scope: 'abstract'` declares a contract that a provider must satisfy later. See\n**[Abstract services](/guide/app/abstract-services)**.\n\n## Startup work\n\n`craftService` also supports startup hooks through `appStart: true` and `yield* onAppStart(...)`.\n\nThe callback can be a plain function or a generator function. Use the generator form when startup logic needs to `yield*` crafted dependencies:\n\n\n\n\nDependencies used only inside that callback are still tracked on the parent service.\n\n## Pitfalls\n\n**Reaching for `global` by default.** A global service is a singleton for the\nwhole app, whether or not that was intended. Start at `function` — see\n[Service scopes](/guide/app/service-scopes).\n\n**`toProvide` without the provider.** A missing provider is reported by the route\nat compile time; the failure appears at runtime. The\n[route DI check](/guide/routing/setup) is what closes that hole.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) keep that\ncheck from quietly disappearing — a `CanRun` alias that nobody references still\ncompiles.\n\n**Returning the whole world.** What a service returns is its API. Return the\nnarrow thing; consumers that need more can yield more.\n\n## See Also\n\n- [Service scopes](/guide/app/service-scopes) — the one decision to make\n- [Shaping the public API](/guide/app/expose-api)\n- [Testing services](/guide/testing/services)\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "/guide/app/expose-api",
|
|
64
|
+
"title": "Shaping a service's public API",
|
|
65
|
+
"body": "# Shaping a service's public API\n\nA service returns whatever should be public. These are the ways to consume less\nthan everything a dependency exposes — which keeps the dependency graph precise,\nand therefore keeps inference and test registers small.\n\n## Single Property Shortcut\n\nWhen only one public property is needed, `X.property()` is a shortcut for\na one-property derivation.\n\n\n\n\nFor method properties on services without public inputs, the shortcut can call\nthe method directly:\n\n```typescript\nreturn yield * UsersApi.updateUser({ id: '1', name: 'Romain' });\n```\n\nThe shortcut accepts the same bindings as `X(...)`:\n\n```typescript\nconst increment = yield* Counter.increment({ initialValue: startAt });\n```\n\nUse the full `X(bindings, expose)` form when deriving several\nproperties, creating aliases, exposing `$self`, using symbol keys, or when a\nservice property collides with a native function property such as `name`.\n\n## Property Shortcut\n\nThe same shortcut notation is available on the generated `X` helper. Use it\ninside a craft generator when only one property is needed:\n\n\n\n\n\nThe result carries the same dependency tracking as `yield* UsersApi()`, so\ntesting utilities see exactly which property was accessed.\n\nFor method properties on services without public inputs, the shortcut calls the\nmethod directly:\n\n```typescript\nconst update = yield * UsersApi.updateUser({ id: '1', name: 'New' });\n```\n\n## Nested Property Shortcuts\n\nWhen only a sub-property of a service output is needed, add a second `.property`\nbefore calling:\n\n\n\n\n\nThe dependency graph records only the accessed nested property\n(`derivedPropertiesUsed: { usersQuery: { isLoading: ... } }`), not the full\n`usersQuery` object. Testing utilities therefore only require the used\nsub-property in mock objects.\n\nThe result of `yield* X.parent.child()` carries the same tracked dependency\nmetadata, so `ExtractDeps` correctly surfaces the service dependency.\n\n## OmitInputs\n\nWhen a service has public inputs, the no-arg form of a property shortcut is\nintentionally disabled at the type level, because calling without bindings would\nsilently use default values and mask a missing dependency:\n\n\n\n```typescript\n// Fine — bindings are explicit\nconst count = yield* Counter.count({ initialValue: startAt });\n\n// Type error — no-arg call is forbidden when inputs exist\n// Counter.count();\n```\n\nUse `X.OmitInputs.property()` to\nexplicitly opt out of input bindings and use the defaults:\n\n```typescript\nconst count = yield* Counter.OmitInputs.count();\nconst count2 = yield* Counter.OmitInputs.count();\n```\n\n`OmitInputs` is purely a type-level gate — at runtime it is transparent.\n\n`OmitInputs` composes with nested shortcuts:\n\n```typescript\nconst isLoading = yield* Counter.OmitInputs.userQuery.isLoading();\n```\n\n## Partial Exposure\n\n`yield* X()` can expose only the part of a dependency that should remain public.\n\n\n\n\nThis keeps the dependency graph precise, which is important for both type inference and testing.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Testing services](/guide/testing/services)\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "/guide/app/lazy-services",
|
|
69
|
+
"title": "Lazy services",
|
|
70
|
+
"body": "# Lazy services\n\n`craftLazy(load)` code-splits a **service**, the way `loadComponent` code-splits a\ncomponent — and reuses the same retry and cache-busting engine.\n\n**Use it when** an expensive dependency is only needed on some paths: a PDF\nrenderer, a chart library, an admin-only API client.\n**Not for** a service every route needs — the extra round-trip buys nothing.\n\n`craftLazy(load)` lazily imports a module **on demand** from inside an async craft driver — an\n[`asyncProcess`](/guide/state/async-process) loader, or a route guard/resolver — reusing the\nexact same retry + cache-busting engine as route lazy loading\n([`loadComponent` / `loadChildren`](/guide/routing/route-load-errors)).\n\nUse it when you want to code-split a **service function** (an exported `craftGen`, an API helper, a\nheavy computation) and only fetch its chunk when it is actually needed, while keeping Craft's\n`status` / `exception` / `reload` semantics.\n\n## Why not a manual dynamic import?\n\nThe reflex is to reach for a manual `import()` and inject/call the result imperatively\n(an `injectAsync`-style helper):\n\n```ts\n// ❌ manual: no status, no typed exceptions, no retry, not reactive\nasync function runSearch(q: string) {\n const { search } = await import('./search'); // may throw on a stale chunk\n return search(q); // exceptions are untyped, failures are unhandled\n}\n```\n\n`craftLazy` replaces that with a first-class craft program:\n\n| Concern | Manual `import()` | `craftLazy` |\n| ------------------------------------ | ---------------------- | -------------------------------------------------------- |\n| Loading / resolved / exception state | you wire it by hand | inherited from the enclosing `asyncProcess` (`status()`) |\n| Stale-chunk retry after a redeploy | none | shared `withRetry` cache-busting engine |\n| Import failure | an unhandled rejection | a typed `CRAFT_LAZY_LOAD_ERROR` exception |\n| The module's own business exceptions | erased to `any` | preserved and propagated through the type system |\n| Recovery | manual `try/catch` | `.pipe(catchTag(...))` or route `handleExceptions` |\n\n## Signature\n\n```ts\ncraftLazy<T>(load: (helpers: CraftLazyLoadHelpers) => Promise<T>): CraftGenInvocation<never, T | CraftLazyLoadError>;\n\ninterface CraftLazyLoadHelpers {\n // Wrap the dynamic import so a chunk whose hashed URL went stale after a\n // redeploy is re-fetched with a cache-busting query param.\n withRetry<T>(moduleImport: Promise<T>): Promise<T>;\n}\n```\n\n- `craftLazy(...)` is a [`craftGen`](/guide/concepts/generators) program: `yield*`-composable and\n [`.pipe(...)`](/guide/advanced/program-operators)-able.\n- Its resolved value is the module `T`, **untouched** — the module's exported `craftGen`s keep their\n own exception unions.\n- On a final import failure it returns a `CraftLazyLoadError` (`code: 'CRAFT_LAZY_LOAD_ERROR'`),\n which `craftGen` surfaces as a short-circuit → the enclosing resource's `status()` becomes\n `'exception'`.\n\n::: warning It must run in an async driver\n`craftLazy` awaits its import through the async program pump, so it can only be `yield*`-ed from an\n**`asyncProcess` loader** or a **route guard/resolver**. It cannot be used inside a synchronous\n[`craftMethod`](/guide/reactivity/craft-method) (that driver throws on an await request). A `craftMethod`\nmay only _trigger_ the enclosing `asyncProcess`.\n:::\n\n## With `asyncProcess`\n\nThe module to split — an exported `craftGen`:\n\n```ts\n// search.ts (its own chunk)\nimport { craftGen } from '@craft-ts/core';\nimport { SearchApi } from './search-api';\n\nexport const search = craftGen(function* (q: string) {\n const api = yield* SearchApi();\n return yield* api.search(q); // may raise E1 | E2\n});\n```\n\nLoad it from an `asyncProcess` loader. The simplest form triggers on demand with the generated\n`method`:\n\n```ts\nimport { asyncProcess, craftLazy } from '@craft-ts/core';\n\nconst searchModule = yield* asyncProcess('searchModule', {\n method: () => undefined, // call searchModule.method() to start loading\n loader: function* () {\n return yield* craftLazy(({ withRetry }) => withRetry(import('./search')));\n },\n});\n```\n\n`searchModule.status()` walks `idle → loading → resolved` (or `exception`), exactly like any other\n`asyncProcess`, so the template can drive the UI:\n\n```html\n@switch (searchModule.status()) { @case ('loading') { <spinner /> } @case\n('exception') { <button (click)=\"searchModule.reload()\">Retry</button> } }\n```\n\nTo **prefetch** as soon as some event fires (the reactive equivalent of an eager\n`injectAsync`), bind the process to a source instead of a `method`:\n\n```ts\nimport { asyncProcess, craftLazy, on$ } from '@craft-ts/core';\n\nconst searchModule = yield* asyncProcess('searchModule', {\n // load at the first emission of the source (e.g. on focus of the search box)\n method: on$(searchFocused$, () => undefined),\n loader: function* () {\n return yield* craftLazy(({ withRetry }) => withRetry(import('./search')));\n },\n});\n```\n\n### Load once, use many\n\nThe canonical pattern: one `asyncProcess` owns the module, a second one awaits it with\n[`craftUntilSettled`](/guide/routing/guards) and calls the loaded function. Wrapping both in a\n[`craftService`](/guide/app/craft-service) exposes a clean API:\n\n```typescript\nimport {\n asyncProcess,\n craftLazy,\n craftService,\n craftUntilSettled,\n on$,\n} from '@craft-ts/core';\n\nconst { Search } = craftService({ name: 'Search', scope: 'component' }, () => {\n // prefetch the module at the first emission of the source\n const searchModule = yield* asyncProcess('searchModule', {\n method: on$(searchFocused$, () => undefined),\n loader: function* () {\n return yield* craftLazy(({ withRetry }) =>\n withRetry(import('./search')),\n );\n },\n });\n\n // run a search on a user action — triggerSearch(q) sets the params\n const searchResult = yield* asyncProcess('searchResult', {\n method: (q: string) => q,\n loader: function* ({ params: q }) {\n const { search } = yield* craftUntilSettled(searchModule); // wait for the chunk\n return yield* search(q);\n },\n });\n\n return { searchModule, searchResult };\n});\n```\n\n\n\nException propagation is fully typed, with **no** manual plumbing:\n\n- `craftLazy` may add `CRAFT_LAZY_LOAD_ERROR`;\n- `craftUntilSettled(searchModule)` relays it to `searchResult`;\n- `search(q)` relays its own `E1 | E2`.\n\nSo `searchResult.exception()?._tag` is exactly `'CRAFT_LAZY_LOAD_ERROR' | 'E1' | 'E2'`, and\n`searchResult.value()` keeps the return type of `search`.\n\n## In routes\n\nGuards and resolvers are async drivers too, so you can `yield* craftLazy(...)` directly inside them.\nA failed import surfaces as `CRAFT_LAZY_LOAD_ERROR` and flows into the route's\n[exception handlers](/guide/concepts/exceptions), exactly like any other guard/resolver exception:\n\n```ts\ncraftRoute(\n 'search',\n {\n resolve: craftResolve(function* () {\n const { search } = yield* craftLazy(({ withRetry }) =>\n withRetry(import('./search')),\n );\n return yield* search('*');\n }),\n },\n {\n CRAFT_LAZY_LOAD_ERROR: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'offline' });\n }),\n E1: craftExceptionHandler(function* () {\n return [] as Result[];\n }),\n // E2 left unhandled → surfaces as a route exception\n },\n);\n```\n\nThis is the code-splitting counterpart of a lazy `loadComponent`: instead of splitting the\n_component_, you split the _data-loading logic_ it depends on, with the same retry + error screen\nguarantees as [Route Load Errors](/guide/routing/route-load-errors).\n\n## Handling the load error\n\n`CRAFT_LAZY_LOAD_ERROR` is an ordinary craft exception, so all the usual tools apply.\n\n**Catch it at the source** (fall back to another module or a default), which removes it from the\nexception union:\n\n```ts\nloader: function* () {\n return yield* craftLazy(({ withRetry }) => withRetry(import('./search'))).pipe(\n catchTag('CRAFT_LAZY_LOAD_ERROR', function* () {\n return yield* craftLazy(({ withRetry }) => withRetry(import('./search-fallback')));\n }),\n );\n}\n```\n\n**Catch a business exception of the loaded function** — `search(q)` is itself a pipeable `craftGen`:\n\n```ts\nloader: function* ({ params: q }) {\n const { search } = yield* craftUntilSettled(searchModule);\n return yield* search(q).pipe(\n catchTag('E1', function* () { return [] as Result[]; }),\n // E2 stays in searchResult.exceptions()\n );\n}\n```\n\n**Read it reactively** — anything left uncaught keeps `status()` at `'exception'` and shows up in\n`exceptions()` / `hasException()`, ready to render in the template.\n\nSee [Program Operators](/guide/advanced/program-operators) for `catchTag` / `catchTag.exhaustive`.\n\n## Retry & cache-busting\n\n`withRetry(import(...))` is what makes a stale chunk recover after a redeploy: on failure the chunk\nURL is re-fetched with a cache-busting query param. The attempt/back-off policy is injectable and\ndefaults to the shared craft loader retry (one retry, 250 ms):\n\n```ts\nimport { provideCraftLazyLoadRetry } from '@craft-ts/core';\n\nproviders: [\n provideCraftLazyLoadRetry({\n attempts: 2,\n delayMs: (error, ctx) => 250 * ctx.attempt,\n shouldRetry: (error) => isRecoverable(error),\n }),\n];\n```\n\nThe dynamic `import(url)` used for cache-busting is itself overridable through `CRAFT_DYNAMIC_IMPORT`\n(useful in tests). This is the very same engine as [route load retry](/guide/routing/route-load-errors), so a\n`craftLazy` import and a lazy route load behave identically under a bad deployment.\n\n## API\n\n| Export | Purpose |\n| ------------------------------------------------------------- | ---------------------------------------------------------------- |\n| `craftLazy(load)` | Lazily import a module from an async craft driver. |\n| `CraftLazyLoadHelpers` | The `{ withRetry }` helpers passed to `load`. |\n| `CraftLazyLoadError` / `CRAFT_LAZY_LOAD_ERROR_CODE` | The exception (and its code) returned on a final import failure. |\n| `provideCraftLazyLoadRetry(config)` / `CRAFT_LAZY_LOAD_RETRY` | Configure the `craftLazy` retry policy. |\n| `CRAFT_DYNAMIC_IMPORT` | Override the dynamic `import(url)` (cache-busting / tests). |\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [asyncProcess](/guide/state/async-process) — the usual driver for `craftLazy`\n- [Route load errors](/guide/routing/route-load-errors) — the same retry engine\n"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"path": "/guide/app/register",
|
|
74
|
+
"title": "craftRegisterFor",
|
|
75
|
+
"body": "# craftRegisterFor\n\n`craftRegisterFor` exposes, within a Craft injection scope, the services,\ncomponents and directives that are **currently alive** in it.\n\n**Use it when** a parent must drive several children without each child having to\npush a bespoke API upwards: counters, audio players, selected items, validation\nacross a form section.\n**Not when** one known child is involved — pass it a service or an input\ninstead. A registry trades explicitness for reach.\n\n## Declaring a registry\n\nThe registry is typed from the Craft targets it accepts:\n\n```ts\nimport { craftComputed, craftRegisterFor } from '@craft-ts/core';\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n```\n\nThe first argument is the registry's mandatory name. It generates the two public\nhelpers `RegisterForCounter` and `provideRegisterForCounter`, a convention that\nlets several registries coexist in one scope without name collisions.\n\nWith a single target, the array can be omitted:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor(\n 'Counter',\n Counter,\n ({ Counter }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n }),\n);\n\nconst counters = yield* RegisterForCounter();\nconst total = craftComputed('total', function* () {\n return (yield* counters())?.length ?? 0;\n});\n```\n\nIf a projection uses several groups, every target must be declared:\n\n```ts\ncraftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n total: craftComputed('total', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n }),\n);\n```\n\nThen add the providers returned by `provideRegisterForCounter()` to the scope\nthat should observe the instances:\n\n```ts\nexport const RegisterForDemo = craftComponent({\n name: 'RegisterForDemo',\n providers: [provideRegisterForCounter()],\n // ...\n});\n```\n\nBy default the registry also includes `global` services resolved under that\nscope. To restrict observation to services whose scope matches the parent:\n\n```ts\ncraftRegisterFor('Counter', [Counter], { includeGlobal: false });\n```\n\nThe first declared target is reachable through `RegisterForCounter()` directly;\nadditional targets get their own property, e.g.\n`RegisterForCounter.CounterChild()`.\n\n## The common case — driving child components\n\nEach child creates a `toProvide` service, and the parent providing the registry\nobserves them:\n\n```ts\nconst { Counter, provideCounter } = craftService(\n { name: 'Counter', providedIn: 'toProvide' },\n function* () {\n const counter = yield* state(\n 'counter',\n 0,\n ({ update }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n }),\n );\n\n return counter;\n },\n);\n\nconst CounterChild = craftComponent(\n 'CounterChild',\n { providers: [provideCounter()] },\n function* () {\n return yield* Counter();\n },\n ({ counter }) => div(counter),\n);\n\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n);\n\nconst CounterBoard = craftComponent(\n 'CounterBoard',\n { providers: [provideRegisterForCounter()] },\n function* () {\n const counters = yield* RegisterForCounter();\n const children = yield* RegisterForCounter.CounterChild();\n\n return {\n incrementAll: function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n },\n childCount: craftComputed('childCount', function* () {\n return (yield* children())?.length ?? 0;\n }),\n };\n },\n ({ incrementAll, childCount }) =>\n section([\n button({ click: incrementAll }, 'Increment every child'),\n p(function* () {\n return `Active children: ${yield* childCount()}`;\n }),\n each([1, 2, 3], () => CounterChild({})),\n ]),\n);\n```\n\nWhen a child is added, its `Counter` appears in the group. When it leaves the\nDOM, the group updates on its own.\n\n## Reading a group\n\nGroups are yieldable from a Craft factory. Their signal is `undefined` while no\ninstance is registered, and returns to `undefined` when the last one is\ndestroyed:\n\n```ts\nconst counters = yield* RegisterForCounter();\n\nconst incrementAll = function* () {\n for (const { ref } of (yield* counters()) ?? []) {\n yield* ref.increment();\n }\n};\n```\n\nEach entry carries:\n\n- `ref` — the value produced by the service, or the context returned by the\n component/directive factory;\n- `hostName` — the name of the host scope that created the entry.\n\nThe signal is live: the parent never re-subscribes when a child appears or\ndisappears.\n\n## Partial exposure\n\nAs with `craftService`, a group can expose only the façade the parent needs. The\nfirst argument stays `undefined` to keep the yieldable-helper syntax, and\n`$self` is the group's full signal:\n\n```ts\nconst childComponents = yield* RegisterForCounter.CounterChild(\n undefined,\n ({ $self }) => ({\n total: craftComputed(function* () {\n return (yield* $self())?.length ?? 0;\n }),\n incrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAll: function* () {\n for (const { ref } of (yield* $self()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nThe parent then keeps only `total`, `incrementAll` and `decrementAll`. The\ndependency stays precise — the computed values read the group's signal, and\ninstances are still added and removed automatically.\n\n## Derived registry properties\n\nTo share common projections, the second parameter of `craftRegisterFor` receives\nthe groups' signals directly:\n\n```ts\nconst { RegisterForCounter, provideRegisterForCounter } = craftRegisterFor(\n 'Counter',\n [Counter, CounterChild],\n ({ Counter, CounterChild }) => ({\n totalCounter: craftComputed('totalCounter', function* () {\n return (yield* Counter())?.length ?? 0;\n }),\n incrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.increment();\n }\n },\n decrementAllCounterChild: function* () {\n for (const { ref } of (yield* CounterChild()) ?? []) {\n yield* ref.decrement();\n }\n },\n }),\n);\n```\n\nEach derived property becomes a yieldable helper:\n\n```ts\nconst totalCounter = yield* RegisterForCounter.totalCounter();\nconst incrementAll = yield* RegisterForCounter.incrementAllCounterChild();\n\nconsole.log(yield* totalCounter());\nyield* incrementAll();\n```\n\nFor a single-target registry, the main call also returns the signal enriched\nwith those derived properties — so the value stays callable for the raw entries\nwhile exposing `total` and the added methods:\n\n```ts\nconst childComponents = yield* RegisterForCounterChild();\n\nconst entries = yield* childComponents();\nconst total = yield* childComponents.total();\nyield* childComponents.incrementAllChildCounter();\nyield* childComponents.decrementAllChildCounter();\n```\n\nIn a Craft template, pass a method straight to an event and call signals inside\na reactive callback:\n\n```ts\nbutton({ click: childComponents.incrementAllChildCounter }, 'Increment all');\nspan(function* () {\n return `Children: ${yield* childComponents.total()}`;\n});\n```\n\nThe main group, the additional groups and the derived properties can all be used\ntogether. Derived properties are computed once per registry injector and keep\nthe reactive signals the groups provide.\n\n## Registering a directive\n\nCraft directives can be targets too:\n\n```ts\nconst { RegisterForCounter } = craftRegisterFor('Counter', [\n CounterChild,\n CounterDebugDirective,\n]);\n\nconst debugEntries = yield* RegisterForCounter.CounterDebugDirective();\ndebugEntries()?.forEach(({ hostName, ref }) => {\n console.debug('directive active', hostName, ref);\n});\n```\n\nA functional directive has no class instance, so `ref` is the factory context of\nthe decorated component. Its `hostName` remains specific to the directive and\nits instance, which is what lets you tell several identical directives apart on\nthe same screen.\n\n## Lifecycle and references\n\nServices are registered when their yield resolves. The runtime attaches their\nremoval to the destruction of the injector that carries them.\n\nCraft components and directives are functional factories with no class instance,\nso `ref` is their factory context. For a directive used with `.pipe(...)`, the\nfinal component's context is exposed, because that is the execution scope the\ndirective shares.\n\nEvery Craft component automatically gets a host tag of the form\n`component:<ComponentName>#<id>`, so `provideHostName` is not needed in a\ncomponent's providers — it stays useful only to override that automatic name.\nDirectives applied to an element get their own `hostName`, generated from the\ndirective name and an instance id. These names distinguish two identical\ninstances and are usable for diagnostics and observability.\n\nEntries are removed automatically in every case: destruction of the\ncomponent/directive, destruction of its DI scope, or replacement of a\ncomposition.\n\n## Pitfalls\n\n::: warning An empty registry is not an error\nCompilation checks that the target you pass to `craftRegisterFor` is a valid\nCraft service, component or directive — but it cannot check that an instance\nwill ever be created. If no registered target exists in the executed code, there\nis no compile error and no runtime error: the signal is simply `undefined`.\n:::\n\n::: warning Craft targets only\n`craftRegisterFor` does not detect arbitrary classes. It targets\n`craftService`, `craftComponent` and `craftDirective`, whose scope and lifecycle\nthe runtime knows.\n:::\n\n**Declaring the same target twice** in the list is not supported — each target\nappears once.\n\n**Treating the group signal as always populated.** It is `undefined` before the\nfirst instance and after the last one; the `?.` is not optional.\n\n::: details Extending the mechanism — target and yield wrappers\nThe registry rests on two separate pieces:\n\n1. a **yield wrapper** observes services as they are actually resolved;\n2. the component/directive **runtime** reports their creation and ties cleanup\n to their lifecycle.\n\nThe first is `provideCraftTargetWrapper`, documented on\n[Target wrapper](/guide/app/target-wrapper). The second is\n`provideServiceYieldWrapper`, the low-level hook `craftRegisterFor` uses to wrap\nevery Craft service resolution in the scope where the yield runs — deliberately\nclose to `provideFnWrapper`, but limited to service yields:\n\n```ts\nimport {\n provideServiceYieldWrapper,\n type ServiceYieldContext,\n} from '@craft-ts/core';\n\nfunction* reportServiceYield(\n context: ServiceYieldContext,\n next: () => Generator<unknown, unknown, unknown>,\n) {\n const startedAt = performance.now();\n const value = yield* next();\n\n console.debug('service resolved', {\n name: context.name,\n hostScope: context.hostScope,\n duration: performance.now() - startedAt,\n });\n\n return value;\n}\n\nexport const providers = [\n provideServiceYieldWrapper(\n 'Warning: the wrapper runs in the current Craft injection context.',\n reportServiceYield,\n ),\n];\n```\n\n`context.resolve()` resolves the real service; `next()` keeps the wrapper chain\nintact. Wrappers compose in registration order — the first is the outermost. The\ncontext provides `name`, `scope`, `hostScope`, `injector` and `resolve`. Like\n`provideFnWrapper`, this hook suits cross-cutting concerns — registries,\nmetrics, traces, diagnostics — not business logic.\n\nA new tool can reuse `provideServiceYieldWrapper` to observe services without\n`craftRegisterFor` at all. For functional Craft targets the runtime also exposes\nits internal registration primitives, so another specialised view can be built —\nbut `craftRegisterFor` stays the recommended application-level API.\n:::\n\n## See Also\n\n- [Target wrapper](/guide/app/target-wrapper) — the extension point underneath\n- [craftService](/guide/app/craft-service)\n- [Customization](/guide/components/customization)\n"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"path": "/guide/app/service-scopes",
|
|
79
|
+
"title": "Service scopes",
|
|
80
|
+
"body": "# Service scopes\n\n`scope` decides how many instances of a `craftService` exist and who has to\nprovide it. It is the one decision to make when declaring a service.\n\n::: tip Short version\nDefault to `function`. Move to `toProvide` the day a child component needs the\nsame instance. Use `global` only for genuinely app-wide state.\n:::\n\n## Supported Scopes\n\n### `global`\n\n- singleton provided at root\n- ideal for app-wide services and shared state\n- no explicit `provideX()` helper\n\n### `toProvide`\n\n- requires `provideX()` where the service is mounted\n- useful for feature-local service trees\n- works well with tests that need explicit providers\n\n### `manuallyProvidedAtRoot`\n\n- explicit provider helper, but designed to be mounted at root\n- also exposes `XToProvide` for public provider composition\n- allows this scope to be yielded by global services, which is not possible with `toProvide` (it still requires explicit setup when testing with `setupCraftServiceTestingByRegister`).\n\n### `function`\n\n- creates a fresh instance on each injection\n- useful for reusable factories with bindings and inputs\n\n### `abstract`\n\n- declares a contract without implementation\n- exposes a requirement token to force a concrete implementation later\n\n## Recommendations For Choosing a Scope\n\n- Prefer `function` for a service owned by a single component. It avoids an explicit provider and makes it clear the instance is not meant to be shared with other components or child components.\n- Move to `toProvide` when the same instance must be shared with child components, or across several components through a common parent or route. In that case, provide it at the component boundary, a parent component, or the route.\n- Be careful with `toProvide`: a missing provider is a runtime failure unless the route DI check is armed. The [route DI check](/guide/routing/setup) and [architecture tests](/guide/testing/architecture#assertroutediproofs) keep that proof in place.\n- Use `global` when the instance is intentionally shared application-wide.\n- For startup-only logic that should run when the app boots but is not injected elsewhere, prefer `function` together with `provideAppInitializer(...)`. If the same instance also needs to be injected by other services, use `global` instead.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Route providers](/guide/routing/route-providers) — providing a service from a route\n- [Testing services](/guide/testing/services)\n"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"path": "/guide/app/target-wrapper",
|
|
84
|
+
"title": "Target wrapper",
|
|
85
|
+
"body": "# Target wrapper\n\n`provideCraftTargetWrapper` wraps the registration of **every Craft component or\ndirective created in the current injector**, giving you a hook at the moment each\none comes to life.\n\n**Use it when** you need to observe or enrich target registration across a\nsubtree: a specialised registry, observability, host names decorated with tags.\n**Not when** you just need a parent to drive its children —\n[`craftRegisterFor`](/guide/app/register) is built on this and already does it.\n\n::: warning Dependency injection here is not type-checked\nThe callback runs in a runtime chain, outside the usual DI inference. A service\nthat is not provided in the current injector fails **at runtime**, and the\nwrapper's type cannot catch it. That is why the first argument is a mandatory\nwarning string.\n:::\n\n## The common case\n\n```ts\nimport { provideCraftTargetWrapper } from '@craft-ts/core';\n\nconst provideTargetCustomization = provideCraftTargetWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (context, next) {\n return yield* next();\n },\n);\n```\n\nThe callback is a generator, so it can yield a Craft service:\n\n```ts\nconst provideTargetAudit = provideCraftTargetWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (context, next) {\n const audit = yield* TargetAuditService();\n audit.recordCreatedTarget(context.kind, context.name);\n\n return yield* next();\n },\n);\n```\n\n## The context\n\n```ts\ntype CraftTargetContext = {\n target: unknown;\n kind: 'component' | 'directive';\n name: string;\n ref: unknown;\n hostName: string;\n injector: Injector;\n};\n```\n\n`target`, `kind`, `name` and `ref` describe the real instance and are immutable.\n**`hostName` is the only field you can change**, by passing it to `next(...)`.\n\n## Tagging the target name\n\n```ts\nimport { HOST_TAG_LIST, provideCraftTargetWrapper } from '@craft-ts/core';\n\nconst provideTagBasedTargetRegistration = provideCraftTargetWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (context, next) {\n const tags = context.injector.get(HOST_TAG_LIST, []);\n const hostName =\n tags.length === 0\n ? context.hostName\n : `${tags.join('/')}/${context.hostName}`;\n\n return yield* next({ hostName });\n },\n);\n```\n\nInstall it in the component's scope:\n\n```ts\nconst RegisterForDemo = craftComponent(\n 'RegisterForDemo',\n {\n providers: [provideTagBasedTargetRegistration],\n },\n // ...\n);\n```\n\nOrder matters — a wrapper that modifies the `hostName` a registry consumes must\nbe declared **before** that registry's wrapper:\n\n```ts\nproviders: [\n provideTagBasedTargetRegistration,\n provideRegisterForCounter(),\n],\n```\n\nWrappers chain in declaration order; the first is the outermost, exactly like\n`provideFnWrapper`.\n\n## `next()` and cleanup\n\n`next()` continues the chain and **returns a release function**, because the\nwrappers after yours may have added a registration or a resource of their own.\n\nA wrapper that only adapts the `hostName` just delegates:\n\n```ts\nfunction* wrapper(context, next) {\n return yield* next({ hostName: `tag:${context.hostName}` });\n}\n```\n\nA wrapper that creates its own resource must combine both cleanups:\n\n```ts\nconst provideObserver = provideCraftTargetWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (context, next) {\n const releaseNext = yield* next();\n const releaseObserver = observeTarget(context);\n\n return () => {\n releaseObserver();\n releaseNext();\n };\n },\n);\n```\n\nThe runtime calls the cleanup automatically when the component's injector is\ndestroyed. For a directive, it runs when the rendered node is removed.\n\n## Pitfalls\n\n**Dropping the release function from `next()`.** Everything registered further\ndown the chain then leaks. Always return it, alone or combined with your own.\n\n**Declaring the wrapper after the registry it should influence.** The registry\nwill have already consumed the unmodified `hostName`.\n\n**Assuming a yielded service exists.** Nothing checks it here — a missing\nprovider is a runtime failure.\n\n::: details Building a specialised registry\nA registry can use the wrapper directly, without depending on\n`craftRegisterFor`:\n\n```ts\nconst provideSpecializedRegistry = provideCraftTargetWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (context, next) {\n const registry = yield* SpecializedRegistry();\n const releaseNext = yield* next();\n const releaseRegistry = registry.add({\n kind: context.kind,\n name: context.name,\n ref: context.ref,\n hostName: context.hostName,\n });\n\n return () => {\n releaseRegistry();\n releaseNext();\n };\n },\n);\n```\n\nThis is how you build registries by tag, by component kind, by scope or by\nbusiness need, while reusing the same lifecycle as `craftRegisterFor`.\n:::\n\n## See Also\n\n- [craftRegisterFor](/guide/app/register) — the built-in registry on top of this\n- [Observability](/guide/advanced/observability)\n"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "/guide/components",
|
|
89
|
+
"title": "Components",
|
|
90
|
+
"body": "# Components\n\nA Craft component is a **function**, not a class. No decorator, no separate\ntemplate file, no host element wrapped around your markup.\n\n**Use it for** application components. [`loadCraftComponent`](/guide/routing/setup)\nmounts a Craft component on a route.\n\n## Install\n\nThe component renderer is published as a separate package and is currently on\nthe `beta` channel:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\n```\n\nSee [`@craft-ts/component` on npm](https://www.npmjs.com/package/@craft-ts/component).\n\n## The shape\n\n```typescript\ncraftComponent(name, meta, factory, template);\n```\n\n| Argument | What it is |\n| ---------- | ----------------------------------------------------------------- |\n| `name` | the component's name — used for host tags, snapshots, diagnostics |\n| `meta` | `providers`, `styles`, `host`, `contentStyles` |\n| `factory` | the **logic**: builds and returns the context |\n| `template` | receives that context, returns nodes |\n\n\n\n\nThe split matters: the factory produces a context **without touching the DOM**,\nand the template renders a context **without running the factory**. That is what\nmakes the two [testable independently](/guide/testing/components).\n\n## The logic factory\n\nA `function*` when it needs dependencies — every `yield*` is tracked and folds\ninto the component's dependency type:\n\n```typescript\nfunction* () {\n const tasks = yield* TaskList();\n return { tasks };\n}\n```\n\nA plain arrow when it needs none:\n\n```typescript\n() => ({});\n```\n\nWhatever it returns is the context the template receives. Nothing else is\nexposed.\n\n## Inputs and outputs\n\nThey are **parameters of the factory**, typed with `Input<T>` and\n`Output<Handler>`:\n\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nRendering a child is a function call, so there is no binding layer to get wrong:\n\n```typescript\nUserCard({ user: currentUser, onRemove: removeUser });\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\n## The template\n\nNodes are built with hyperscript helpers — `div`, `ul`, `button`, and `h(tag, …)`\nfor anything without one. Pass a yieldable reader to a binding. Use a generator\nwhen the binding must format or call a method:\n\n```typescript\n({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n h1(`Tasks — static`); // static text needs no reader\n];\n```\n\nThe same binding boundary applies to attributes, DOM properties, classes,\nstyles, and host props. Prefer exposing a derived reader on the primitive\n(`tasks.isEmpty`) and passing it (`disabled: tasks.isEmpty`) over wrapping a\nsynchronous call.\n\nSee [Fine-grained reactivity](/guide/components/fine-grained-reactivity) for\nthe complete rendering model, structural scopes, observability expectations,\nand migration checklist.\n\nSee [Progressive `each` rendering](/guide/components/schedule-each) when a\nlarge collection needs frame-based scheduling.\n\nKeep render callbacks pure. They may read signals and calculate values, but\nmust not call `set`, `update`, or `mutate`. Perform writes from DOM events,\noutputs, mutations, or explicit business effects. Enable\n`craft-ts/no-render-writes` to diagnose common violations.\n\nControl flow is made of functions rather than syntax — `each`, `ifBlock`,\n`matchBlock`, `defer`. The relationship between these blocks, and why a raw\nternary is the wrong tool for **structure**, is in\n[Learn step 2](/learn/02-derive#control-flow).\n\n## The meta\n\n```typescript\ncraftComponent(\n 'Card',\n {\n providers: [provideCardStore()],\n styles: ':scope { padding: 1rem } .title { font-weight: 700 }',\n host: { class: 'card-host' },\n },\n /* … */\n);\n```\n\n- **`providers`** — the component's own DI scope, evaluated before the template.\n- **`styles`** — scoped with CSS `@scope`; `:scope` is this component's root. See\n [Encapsulated styles](/guide/components/styles).\n- **`host`** — default properties for the root element.\n- **`contentStyles`** — styles offered to projected content, per slot. See\n [Content projection](/guide/components/content-projection).\n\n## Composing behaviour\n\n`.pipe(...)` attaches directives, which decorate **both** the logic factory and\nthe template, left to right:\n\n```typescript\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nThe same mechanism carries `withProviders(...)` and the exception handlers below.\nSee [Directives and `.pipe(...)`](/guide/components/directives).\n\n## Mounting the root\n\nThe app root is a Craft component too:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>` (or the element you pass as\n`host`).\n\n## Pitfalls\n\n**Reading a reader outside a binding.** `h1(tasks().length)` evaluates once at\nbuild time. Pass the reader (`p(tasks.remaining)`) or use a generator:\n`h1(function* () { return yield* tasks.remaining(); })`.\n\n**Forgetting `track` in `each`.** Without a stable identity the renderer cannot\nreuse, move or remove the right node.\n\n**Exceptions from the factory or providers don't vanish.** They become the\ncomponent's initialization exceptions and flow up to the route unless handled\nwith `.pipe(catchBlock.exhaustive(...))` — see\n[Exceptions as values](/guide/concepts/exceptions).\n\n**Naming mismatch.** The first argument must match the exported binding; the\n`craft-component-name-match` rule enforces it.\n\n## See Also\n\n- [Learn: your first state](/learn/01-first-state) — the guided version\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Accessibility](/guide/components/accessibility)\n- [Testing components](/guide/testing/components)\n"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"path": "/guide/components/accessibility",
|
|
94
|
+
"title": "Accessibility",
|
|
95
|
+
"body": "# Accessibility\n\nCraft already enforces exhaustive exceptions, `pendingBlock`, and reactive\ntemplates. Accessibility follows the same DNA: **an illegal state doesn't\ncompile, an omission is an ESLint error, the block runtime doesn't wait for\nthe author to remember.**\n\nTarget: **WCAG 2.2 level AA**.\n\n## The five layers\n\n1. **Types** — `img` and `area` require `alt` (including a decorative `''`).\n Semantic helpers (`dialog`, `fieldset`, `table`, `iframe`, `h4`–`h6`,\n `svg`…) exist so the lint applies without going through `h()`.\n2. **ESLint `craft-ts/a11y`** — accessible name, labels, ARIA, no click on a\n `div`, `button` with `type`, `h()` forbidden when a named helper exists.\n3. **Block runtime** — `pendingBlock` announces the fallback (`aria-live`,\n `aria-busy`), `catchBlock` sets `role=\"alert\"`, `defer` renders a keyboard\n placeholder, `CraftRouterLink` sets `aria-current=\"page\"`.\n4. **Primitives** — `heading` / `headingSection` (relative outline), `dialog`\n (native modal + focus), `liveRegion` (toasts). No **styled** button:\n `buttonControl` / `fieldControl` / `disclosureControl` inject accessibility\n props into your native elements.\n5. **Tests** — `toBeAccessible()` on the template helper.\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n plugins: { 'craft-ts': craftRules },\n rules: {\n ...craftRules.configs.a11y.rules,\n },\n },\n];\n```\n\nThe rules are `error` in the preset. A disable is a documented deviation,\nnot the default path.\n\n## Hyperscript templates\n\nA Craft template is TypeScript, not an `.html` file: accessibility rules\noperate on the hyperscript calls themselves — `button(...)`, `img(...)` **and**\n`h('img', …)`.\n\n```ts\nimg({ src: photo.url, alt: photo.title }); // decorative: alt: ''\nbutton({ type: 'button' }, 'Save');\na({ href: '/tasks' }, 'Tasks');\nlabel({ htmlFor: 'email' }, 'Email');\ninput({ id: 'email', type: 'email' });\n```\n\n`h('button')` when a named helper exists is an error\n(`prefer-named-html-helpers`): it's a bypass of the types.\n\n## Heading outline\n\nAn `h3` inside a Card is a classic false positive: sometimes under an `h1`,\nsometimes under an `h2`. The title doesn't choose its rank. **The parent\nsupplies it.**\n\n```ts\nheading('Task list');\n\nheadingSection([\n heading('Detail'),\n TaskCard(), // the internal heading() becomes hN+1\n]);\n```\n\nThe snippet above is the core of the API. The skip-link and `main` belong to\nthe application shell:\n\n- `heading()` reads the current level (1–6) and renders `h1`…`h6`.\n- `headingSection(...)` increments by one for the subtree — comment\n fragments, no DOM wrapper, like `ifBlock`.\n- `headingRoot(...)` resets to `h1` (dialog, explicit reset). A `dialog` also\n sets its own outline root (the dialog title = level 1 **inside** the\n dialog). SFCs loaded via `loadComponent` stay on `heading()`.\n- `h1()`…`h6()` remain for raw HTML. The `prefer-relative-heading` rule\n forbids them inside a `craftComponent` (outside specs).\n\nA reusable component exposes `heading()` without a local `headingSection`:\nthe need for an outline **bubbles up** to the parent. Calling this component\noutside a `headingSection` **doesn't compile** (same DNA as `pendingBlock`).\n\nAny SFC mounted via `loadComponent` / `loadCraftComponent` calls `heading()` —\nnot `headingRoot()`. The rank (h1 vs h2+) comes from the parent:\n\n- **Page** (sibling under the shell): `heading()` is the h1.\n- **Layout** (SFC with `CraftRouterOutlet`): `heading()` +\n `headingSection([…, CraftRouterOutlet()])` so the child inherits h2+.\n- **Shell** (`App`): `skipLink` + `main` + `CraftRouterOutlet`, **without**\n `heading()` above the outlet. Otherwise two h1s, or children stuck at the\n same level as the chrome's title.\n\n`require-route-heading-outline` reads the lazy target.\n`require-outlet-heading-section` distinguishes layout from shell. The types\ndon't connect the outlet to the routed child.\n\n```ts\n// Shell — no heading() above the outlet\nskipLink('main', 'Skip to content');\nmain({ id: 'main', tabIndex: -1 }, CraftRouterOutlet());\n\n// Layout — title + outlet inside headingSection\nheading('Team');\nheadingSection([CraftRouterOutlet()]);\n\n// Page (loadComponent) — heading() only; h1 or h2+ depending on the parent\nheading('Task list');\nheadingSection([\n heading('Detail'),\n TaskCard(),\n]);\n```\n\n## Blocks\n\n`pendingBlock` detaches the source from the document while loading (the\nnodes stay mounted, they aren't CSS `hidden`). The fallback is wrapped in\n`aria-live=\"polite\"` `aria-atomic=\"true\"` `aria-busy=\"true\"`. On reload, the\nsource stays visible; `aria-busy` signals the refresh. Focus in the source is\nrestored when it resumes.\n\n`catchBlock` wraps the error message in `role=\"alert\"` if the fallback isn't\nalready a live region.\n\n`defer` sets `aria-busy` while loading. An `interaction` trigger on a\nplaceholder that isn't already a control gets `role=\"button\"` and\n`tabIndex=\"0\"`, and only fires on keyboard via Enter / Space.\n\n## Dialog and live region\n\n```ts\ndialog(\n { labelledBy: 'title', open: true, onClose },\n [heading({ id: 'title' }, 'Confirm'), button({ type: 'button', click: onClose }, 'Close')],\n);\n\nliveRegion({ politeness: 'polite' }, copied() ? 'Copied' : '');\n```\n\n`dialog` relies on the native `<dialog>` (`showModal`, Escape, `aria-modal`).\n`liveRegion` is a `<span role=\"status\">` (or `alert` if `assertive`).\n\n## Control helpers (props to merge)\n\nThe helpers are renderless: they supply the attributes to merge onto your own\nHTML elements, without imposing a visual widget.\n\n```ts\nconst email = fieldControl('email');\nlabel(email.label, 'Email');\ninput({ ...email.input, type: 'email' });\np(email.description, 'We never share your email.');\n\nconst faq = disclosureControl('faq-1', isOpen);\nbutton({ ...faq.button, click: toggle }, 'What is Craft?');\ndiv(faq.panel, '…');\n\nbutton(buttonControl({ disabled: isSaving, keepFocusable: true }), 'Save');\n```\n\nA closed panel gets `hidden` and `aria-hidden`, so no focus stays inside it.\n`keepFocusable` sets `aria-disabled` without `disabled`: the click isn't cut\noff, the author must no-op the handler.\n\nThe states are also exposed as `data-*`, which allows a simple CSS\nconvention independent of the component:\n\n```css\nbutton[data-disabled] { opacity: 0.5; }\ninput[data-invalid] { border-color: var(--danger); }\nbutton[data-open] { font-weight: 600; }\n```\n\nA live region must be mounted from the very first render: never condition\nits node on the message. This lets the screen reader subscribe to it before\nany event happens.\n\n```ts\n// correct — region exists at first paint\nliveRegion({ label: 'Notifications' }, copied() ? 'Copied' : '');\n\n// incorrect — SR never subscribes\nifBlock(copied, () => liveRegion('Copied'));\n```\n\n## Navigation\n\n`provideCraftRouter` registers `CraftTitleStrategy`: the route's `title` is\nwritten via `BrowserDocument.setTitle`.\n\n`withA11yNavigationFocus()` (opt-in, passed to `provideCraftRouter`) moves\nfocus to `#main` / `<main>` after each internal navigation — not on first\nload, the skip-link handles that.\n\n`skipLink('main', 'Skip to content')` at the top of the shell, with\n`main({ id: 'main', tabIndex: -1 }, …)`.\n\nTo sync the document's language and direction from a generator:\n\n```ts\nyield* BrowserDocument.setLang('en');\nyield* BrowserDocument.setDir('ltr');\n```\n\n`clickFocus` sets focus before running the handler, useful for controls that\nopen a search or a dialog:\n\n```ts\nbutton({\n type: 'button',\n click: clickFocus('#search-warmup', openSearch),\n}, 'Search');\n```\n\n## Tests\n\n```ts\nconst { getByRole, getByLabel, toBeAccessible } =\n await setupCraftComponentTemplateTest(\n Page,\n { context },\n );\nawait toBeAccessible();\ngetByRole('button', { name: 'Save' });\ngetByLabel('Email');\n```\n\n`assertAccessible` / `toBeAccessible()` cover the structural checks (alt,\naccessible name, tabindex, iframe title). Real contrast and the rest of\nWCAG 2.2 AA remain a job for axe / AccessLint in application CI.\n\n## CSS\n\nThe `require-focus-visible` and `require-reduced-motion` rules apply to the\n`styles` of a `craftComponent`: if you style `button` / `a` / `input`, define\n`:focus-visible`; if you animate, gate it with `prefers-reduced-motion`.\nContrast goes through tokens (`no-hardcoded-design-values`), not a second CSS\nlinter.\n"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"path": "/guide/components/content-projection",
|
|
99
|
+
"title": "Content projection",
|
|
100
|
+
"body": "# Content projection\n\nProjection is a **rendering context, not a category of component**. The same\n`craftComponent` can be rendered directly or supplied into a compatible logical\nslot — its definition doesn't change either way.\n\n**Use it when** a component composes content it doesn't own: a card with a\ncaller-supplied body, a toolbar filled with actions, a dialog with its buttons.\n**Not when** the child is fixed — just render it.\n\nBoth forms go through one primitive:\n\n```ts\nrenderContent(value);\n```\n\nIt accepts either deferred DOM content (`RenderableContent`) or a component unit\nexposing a logical contract. There is **no runtime registry** like\n`contentChildren`, and no special projection component.\n\n## The common case — free DOM content\n\n`ContentSlot` describes optional or free-form DOM content. `RequiredContent`\nadds a structural contract that TypeScript checks.\n\n```typescript\nimport {\n content,\n craftComponent,\n div,\n renderContent,\n section,\n type ContentSlot,\n type RequiredContent,\n} from '@craft-ts/component';\n\ntype CardInput = {\n readonly header?: ContentSlot;\n readonly body: RequiredContent<{\n readonly selector: {\n readonly tag: 'div';\n readonly class: 'card-body';\n readonly 'data-slot': 'body';\n };\n }>;\n};\n\nconst Card = craftComponent(\n 'Card',\n {},\n (input: CardInput) => input,\n ({ header, body }) =>\n section([\n header ? renderContent('header', header) : 'Default title',\n renderContent('body', body),\n ]),\n);\n\nCard({\n header: content(() => div('Title supplied by the caller')),\n body: content(() =>\n div({ class: 'card-body', 'data-slot': 'body' }, 'Card content'),\n ),\n});\n```\n\n\n\nThe selector is analysed **statically**. This is rejected, because it does not\ncontain `div.card-body[data-slot=\"body\"]`:\n\n```ts\nCard({\n // @ts-expect-error the content does not satisfy the slot's DOM contract.\n body: content(() => div({ class: 'wrong-class' })),\n});\n```\n\nContent can be built from arrays, conditions, loops and templates — the analysis\nlooks for the selector in every rendered branch:\n\n```ts\nconst body = content(() => [\n showIntro() ? div({ class: 'card-body' }, 'Introduction') : undefined,\n each(rows(), { track: (row) => row.id }, (row) =>\n div({ class: 'card-body' }, row.label),\n ),\n renderTemplate(cardRowTemplate, { $implicit: selectedRow() }),\n]);\n\nCard({ body });\n```\n\nThe constraint creates no wrapper and adds no runtime validation. DOM contracts\nand logical contracts are independent:\n\n```text\nRequiredContent<Requirement> → the shape of the DOM supplied\nProjectionOf<Component> → the logical capabilities of a component\n```\n\n## Logical projection by contract\n\nA component becomes projectable when its logic factory returns a `contract`\nproperty, built and checked with `satisfies`.\n\n\n\n\n`ProjectionContractOf<Component>` extracts the type of `logicOutput.contract`.\n`ProjectionOf<Component>` adds the stable key the renderer expects. For generic\nconsumers, `ProjectionSlot<Contract>` directly describes a collection of\ncompatible units.\n\nProjection therefore depends on **neither** the component's name, **nor** a\n`projection` metadata field, **nor** a runtime registry.\n\n## Explicit collections, order and stable keys\n\nThe consuming component receives a typed collection explicitly. Each unit must\nsupply a **stable key**, which `each` uses to reuse, move or remove the right\nprojection.\n\n```ts\nimport {\n craftComponent,\n div,\n each,\n renderContent,\n type ProjectionOf,\n} from '@craft-ts/component';\n\nconst Toolbar = craftComponent(\n 'Toolbar',\n {},\n (input: {\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ actions }) =>\n div(\n { role: 'toolbar' },\n each(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n);\n\nToolbar({\n actions: [\n ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }),\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }),\n ],\n});\n```\n\nThe same `ToolbarAction` stays usable on its own:\n\n```ts\nconst Page = craftComponent(\n 'Page',\n {},\n () => ({}),\n () => [\n ToolbarAction({\n key: 'standalone',\n content: () => 'Direct action',\n trigger: save,\n }),\n Toolbar({\n actions: [\n ToolbarAction({\n key: 'projected',\n content: () => 'Projected action',\n trigger: save,\n }),\n ],\n }),\n ],\n);\n```\n\n## Styling projected content\n\n`contentStyles` is indexed by the content slot names the component declares. An\nunknown slot name is a type error.\n\n\n\n\nThe **caller** decides explicitly whether its content accepts those styles:\n\n```ts\nStyledCard({\n body: content(() => div('Styled content'), {\n allowContainerStyles: true,\n }),\n});\n\n// without the flag, the content renders but stays isolated\nStyledCard({\n body: content(() => div('Rendered without the container styles')),\n});\n```\n\nExposed styles apply to ordinary DOM nodes in the fragment. They never cross the\nboundary of a nested Craft component:\n\n```ts\nStyledCard({\n body: content(\n () => [\n div('This node can receive contentStyles.body'),\n NestedCraftComponent({}), // independent style boundary\n ],\n { allowContainerStyles: true },\n ),\n});\n```\n\n## Pitfalls\n\n**Forgetting the stable key.** Without it the renderer cannot tell one projected\nunit from another across updates, and reuse breaks.\n\n**Expecting a plain component to satisfy a contract slot.** It stays perfectly\nusable as a direct child, but the slot rejects it:\n\n```ts\nconst PlainCard = craftComponent(\n 'PlainCard',\n {},\n () => ({}),\n () => 'Card with no contract',\n);\n\nToolbar({\n actions: [\n // @ts-expect-error PlainCard does not expose ToolbarActionContract.\n PlainCard({}),\n ],\n});\n```\n\nAn incomplete contract is rejected where it is declared:\n\n```ts\nconst invalidContract = {\n kind: 'toolbar-action',\n // @ts-expect-error trigger and disabled are required.\n} satisfies ToolbarActionContract;\n```\n\n**Styling a slot that isn't one.** `contentStyles` can only reference declared\ncontent slots:\n\n\n\n\n::: details Combining optional content and contractual actions — a dialog\nA component can mix optional DOM content with several logical slots in one\nexplicit collection:\n\n```ts\nconst Dialog = craftComponent(\n 'Dialog',\n {},\n (input: {\n readonly body?: ContentSlot;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ body, actions }) =>\n section({ role: 'dialog' }, [\n body ? renderContent(body) : [],\n footer(\n each(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n ),\n ),\n ]),\n);\n\nDialog({\n body: content(() =>\n div(['Delete the account', 'This action cannot be undone.']),\n ),\n actions: [\n ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: closeDialog }),\n ToolbarAction({ key: 'delete', content: () => 'Delete', trigger: deleteAccount }),\n ],\n});\n```\n\n`closeDialog` and `deleteAccount` are captured by the caller's closures.\nProjection preserves the lexical context **and the injector** of wherever the\nunit or the content was declared.\n:::\n\n::: details Conditions, reactivity and cleanup\nProjections are ordinary Craft nodes, so they can sit inside conditions and\ntemplates while keeping their identity by key within a collection. Here `visible`\nis a callable reactive value supplied by the caller:\n\n```ts\nconst OptionalToolbar = craftComponent(\n 'OptionalToolbar',\n {},\n (input: {\n readonly visible: () => boolean;\n readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];\n }) => input,\n ({ visible, actions }) =>\n visible()\n ? each(actions, { track: (action) => action.key }, (action) =>\n renderContent(action),\n )\n : [],\n);\n```\n\nOn update the renderer adds, removes and moves projections by key. On teardown\nthe projected content, its effects and its styles are cleaned up with the rest\nof the tree.\n:::\n\n## API summary\n\n- `content(renderer, options?)` — create deferred DOM content\n- `renderContent(value)` and `renderContent(slotName, value)` — render it\n- `RenderableContent`, `ContentSlot` — free-form slots\n- `RequiredContent<Requirement>` — static DOM contracts\n- `ProjectionContractOf<Component>` — extract a logical contract\n- `ProjectionOf<Component>`, `ProjectionSlot<Contract>` — type projectable\n collections\n\nThe older fragment and slot primitives are no longer part of the public API.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Encapsulated styles](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"path": "/guide/components/css-variables",
|
|
104
|
+
"title": "Typed CSS variables and design tokens",
|
|
105
|
+
"body": "# Typed CSS variables and design tokens\n\nCSS custom properties are the public styling API of a Craft component. Craft\nextracts a contract from inline `meta.styles`, propagates unsatisfied variables\nthrough component templates, and applies supplied values to the component root.\nThe browser's native inheritance then carries them to descendants.\n\n## Required and optional variables\n\nAn unguarded use is required. A declaration or inline fallback is optional:\n\n\n\n\n`--card-ink` is required, while `--card-bg` and `--card-radius` are optional.\nStyles supplied through `cssVars` are written as custom properties on the\ncomponent root; different instances can therefore use different values while\nsharing one scoped stylesheet.\n\n## External stylesheets\n\nAn imported stylesheet is typed as `string`, so TypeScript cannot inspect it.\nDeclare its contract explicitly with `required()`:\n\n```typescript\ncraftComponent(\n 'ExternalCard',\n {\n stylesUrl: styles,\n cssVars: {\n '--external-card-ink': required<string>(),\n '--external-card-gap': '1rem',\n },\n },\n () => ({}),\n template,\n);\n```\n\n\n\nThe `craft-css-vars-contract` lint rule resolves the CSS import and checks that\nthe explicit contract and file remain synchronized.\n\n## Child-variable dispositions\n\nAt a child call site, every variable can be handled deliberately:\n\n```ts\nBadge({ cssVars: { '--badge-ink': 'navy' } });\nBadge({ cssVars: { '--badge-ink': inherit } });\nBadge({ cssVars: { '--badge-ink': omit } });\nBadge({ cssVars: { '--badge-ink': forward('navy') } });\nBadge({ cssVars: { '--badge-bg': forward() } });\n```\n\n- A value supplies the child directly.\n- `inherit` uses a declaration in the current component's own styles and emits\n no inline value.\n- `omit` intentionally stops propagation and emits nothing.\n- `forward(value)` gives the parent API a default that callers can override.\n- `forward()` re-exposes an optional value without adding a default.\n\nUse `assertCssVarsSatisfied(routes)` next to the other route proofs. It rejects\na routed root when a required variable has propagated all the way to a mount\nthat has no component call site.\n\n## `@property`: validation versus requiredness\n\nCraft reads authored `@property` blocks; it does not generate them. A registered\nproperty with a non-wildcard syntax needs an `initial-value`, so it is optional\nby construction:\n\n```css\n@property --meter-value {\n syntax: '<number>';\n inherits: true;\n initial-value: 0;\n}\n```\n\nRegistration provides browser validation, animation support, and an initial\nvalue. The tradeoff is that it gives up the compile-time “nobody supplied this”\nerror. `inherits: false` cannot be used for a variable supplied or forwarded by\na parent.\n\n`@property` is document-global even when its values cascade normally. A\ncomponent may therefore register only variables in its own namespace\n(`Meter` → `--meter-*`). Register shared design tokens once in the application's\nglobal stylesheet, whose lifetime matches the document.\n\n## Scope safety\n\nCraft rejects component CSS that can silently become global:\n\n- `@import`, `:root`, `html`, and `body`;\n- unprefixed `@keyframes`, `@counter-style`, font palettes, or font families;\n- `@property` registrations outside the component namespace;\n- `!important` in component styles.\n\nPrivate global names use the exact component scope, for example\n`@keyframes Spinner-spin`. Craft validates these names rather than rewriting\nCSS declaration values at runtime.\n"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"path": "/guide/components/customization",
|
|
109
|
+
"title": "Customizing components and directives",
|
|
110
|
+
"body": "# Customizing components and directives\n\nCraft splits customization into three layers, and which one you reach for\ndepends on how far the change should travel:\n\n| Layer | Changes |\n| --------------------- | ------------------------------------- |\n| Root-element `host` | The component's own root defaults |\n| Encapsulated `styles` | Its internal appearance |\n| Composable directives | Behaviour, reusable across components |\n\n**Start with `host`** for one component's defaults, and move to a directive only\nwhen the same customization needs to apply somewhere else too.\n\n## Customizing the root element\n\nThe component meta `host` properties define defaults for the component’s root\nelement. The caller can extend or override them:\n\n\n\n\nClasses, attributes, styles, and events recognized as host properties are\napplied to the component root. Other properties remain factory props.\n\nValues can be reactive:\n\n```ts\nconst { active } = state('active', false, ({ set }) => ({ set }));\n\nCard({\n class: () => (active() ? 'is-active' : 'is-idle'),\n style: () => ({ opacity: active() ? 1 : 0.6 }),\n});\n```\n\n## Customizing with styles\n\nStyles declared in `meta.styles` are shared across instances and encapsulated\nwith `@scope`. The template root is written as `:scope`:\n\n```typescript\nconst Panel = craftComponent(\n 'Panel',\n {\n styles: `\n :scope { padding: 1rem; border: 1px solid #ddd; }\n .title { font-weight: 700; }\n button { cursor: pointer; }\n `,\n },\n () => ({}),\n () => div([h2({ class: 'title' }, 'Panel'), button('Save')]),\n);\n```\n\n\n\nStyles do not leak into descendant components. Global rules such as\n`@keyframes` and `@font-face` cannot be nested in `@scope`, so their private\nnames must start with the component scope. `@import` and document-root selectors\nare rejected. `@media`, `@supports`, and `@container` remain composable inside\nthe scope. For the typed styling API, see\n[Typed CSS variables and design tokens](/guide/components/css-variables).\n\n## Adding reusable customization with a directive\n\nA directive transforms a component’s factory and template. It is applied from\nleft to right with `.pipe(...)`:\n\n```ts\nconst Highlight = craftDirective(\n 'Highlight',\n {\n styles: '.highlight { background: #fff3bf; }',\n },\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) => baseTemplate(context, { class: 'highlight' }),\n);\n\nconst HighlightedPanel = Panel.pipe(Highlight);\n```\n\nA directive can also add context and public props:\n\n```ts\nconst WithPermission = craftDirective(\n 'WithPermission',\n {},\n (baseLogic) => (user: Input<User>) => ({\n ...baseLogic(user),\n canEdit: () => user().permissions.includes('edit'),\n }),\n (baseTemplate) => (context) =>\n context.canEdit() ? baseTemplate(context) : [],\n);\n\nconst EditablePanel = Panel.pipe(WithPermission);\n```\n\nDirective styles are registered in the scope of the component that owns them.\nThe same directive can therefore be reused by several components without\nintroducing an HTML wrapper.\n\n## Composing providers and exception handlers\n\n`withProviders` configures the provider scope of a component before it is\ninvoked. `catchTag.exhaustive` is a logic boundary: each handler is a\ngenerator that can call a service or perform another logic operation. It must\nnot return template children. Use `catchBlock.exhaustive` or\n`matchBlock.exhaustive` when the exception should produce DOM.\n\n```ts\nimport { abstract, craftException, craftService } from '@craft-ts/core';\nimport {\n catchTag,\n craftComponent,\n p,\n withProviders,\n} from '@craft-ts/component';\n\nconst noAccess = craftException({ _tag: 'NO_ACCESS' });\nconst { RestrictedData, provideRestrictedData } = craftService(\n { name: 'restrictedData', scope: 'abstract' },\n abstract<string | typeof noAccess>(),\n);\n\nconst MyRestrictedCraftComponent = craftComponent(\n 'MyRestrictedCraftComponent',\n {},\n function* () {\n return { value: yield* RestrictedData() };\n },\n ({ value }) => p(`Private data: ${value}`),\n);\n\nconst Restricted = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* () {\n // yield* ToastService.show(() => 'No access');\n },\n }),\n);\n\nRestricted();\n```\n\nProviders are evaluated before the component template. If a provider reads a\nsignal, changing that signal recreates the composed rendering, including the\nprovider scope. The handler generator runs for the exception state. Since\n`catchTag` does not render a template, use `catchBlock` or `matchBlock` for a\nvisual fallback.\n\nThe component adapter reuses the exhaustive `catchTag` rules from the core and\nthe composed component carries the exception codes produced by its initializer\nand providers. The providers also participate in the normal Craft DI graph, so\nthey can satisfy dependencies used by the component and its children. The\nvariadic component `.pipe(...)` overload is currently kept permissive to avoid\nexcessive TypeScript instantiation depth; runtime dispatch still rejects an\nunhandled exception code.\n\n## Choosing an exception utility\n\nCraft exposes three complementary utilities. The important distinction is\nwhether the exception is handled in logic or rendered in a template:\n\n- `catchTag.exhaustive` handles component initialization exceptions in logic;\n- `catchBlock.exhaustive` creates a template boundary and can insert a fallback\n before or after its source block;\n- `matchBlock.exhaustive` renders a fallback from an exception value or signal.\n\n### `catchTag.exhaustive`: logic only\n\nHandlers are generator functions. They can call services and yield other Craft\noperations, but they cannot return `p(...)`, an element, or any other template\nchildren. A DOM fallback belongs to `catchBlock` or `matchBlock`.\n\n```ts\nconst SafeComponent = MyRestrictedCraftComponent.pipe(\n withProviders([\n provideRestrictedData(() =>\n currentUserCanRead() ? 'available' : noAccess,\n ),\n ]),\n catchTag.exhaustive({\n NO_ACCESS: function* (exception) {\n yield* ToastService.show(() => `Access denied: ${exception._tag}`);\n },\n }),\n);\n```\n\n### `catchBlock.exhaustive`: preserve a source block\n\nApply it to a rendered VNode when the source subtree may throw. The source is\nkept and the fallback is inserted at the requested position. Applying it to a\ncomponent in `.pipe(...)` also creates a residual component boundary and\nremoves the handled codes from the component and route contracts.\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchBlock.exhaustive(\n {\n UserNotFoundException: () => p('User not found'),\n },\n { position: 'after' },\n ),\n);\n```\n\nFor a template boundary, the source block remains visible by default. When\n`catchBlock` is piped onto a component and the exception comes from its\ncomposed scope, a function handler keeps the existing component behavior and\nreplaces the source. A handler can keep that source visible by using the object\nform and setting `showSource: true`:\n\n```ts\nconst view = SourceComponent({}).pipe(\n catchBlock.exhaustive({\n UserNotFoundException: {\n render: () => p('User not found'),\n showSource: true,\n position: 'after',\n },\n }),\n);\n```\n\nWith `showSource: true`, the source and fallback are both rendered. Use\n`showSource: false` to hide the source explicitly. `position` can be set on\neach handler (`before` or `after`); the second argument remains available as a\ndefault for handlers that do not specify their own position. Existing function\nhandlers keep their previous behavior. If the component factory or a provider\nfails before the template is created, there is no source block to preserve, so\nthe fallback is rendered alone.\n\n### `matchBlock.exhaustive`: render a resource exception\n\nUse it when a query, mutation, or another primitive exposes an exception as a\nsignal instead of throwing from the template subtree. The block renders no\nchildren while the source is empty and switches reactively to the matching\nhandler when an exception appears.\n\n```ts\nmatchBlock.exhaustive(() => userQuery.exceptions().loader, '_tag', {\n UserNotFoundException: () => p('User not found'),\n UserConsentMissingException: () => p('Consent is required'),\n});\n```\n\n## What Craft handles directly\n\nCraft supports compositions that are not native properties of a standard\nthe host component or directive:\n\n- a Craft directive can declare `meta.styles` and contribute to the stylesheet\n of the component using it; Craft keeps the association with the component;\n- directive styles remain encapsulated with `@scope`, without rewriting\n selectors or adding a wrapper;\n- multiple directives can compose their logic, template, host classes, and\n styles through `.pipe(...)`;\n- styles are deduplicated and reference-counted across instances, then removed\n when the last instance is destroyed.\n\nThe directive runtime owns stylesheet injection, scoping, and cleanup, so those\nresponsibilities do not leak into application code.\n\n## Choosing the right level\n\n- `host`: identity, attributes, classes, or behavior of the root element;\n- `styles`: local, reusable component appearance; the stylesheet is shared\n across instances, while its rules remain limited to the component roots;\n- `craftDirective`: behavior or customization reusable across components;\n- the factory: component-specific state and dependencies.\n\n### Understanding style scope\n\nInside `meta.styles`, `:scope` targets every root produced by the template:\n\n\n\n\nCraft puts an internal token on the roots and generates a scope equivalent to:\n\n```css\n@scope ([data-craft-root~=\"Card\"]) to ([data-craft-root] *) {\n /* Card rules */\n}\n```\n\nIn practice:\n\n- `:scope` targets the root itself;\n- `.title` targets `Card` descendants;\n- when a child Craft component is encountered, its root becomes a boundary:\n parent rules can reach the root, but not its internal\n DOM;\n- ordinary elements do not become boundaries and do not receive an additional\n token;\n- a template returning multiple roots scopes each root, but cannot express a\n relationship between sibling roots such as `header + main`;\n- a root that is directly another Craft component can carry multiple tokens.\n The containing component can then reach into the child component: this is a\n known limitation of the current model.\n\nScoping is structural, not based on selector rewriting: modern selectors such\nas `:is()`, `:where()`, `&`, and nested rules are not transformed by Craft.\n`@media`, `@supports`, and `@container` remain inside the scope; rules that\ncannot be nested there, such as `@keyframes`, `@font-face`, `@import`, and\n`@namespace`, are hoisted outside the `@scope` block.\n\nDirective styles use the scope of their owning component because a directive\ndoes not introduce a separate root node. A directive can add `.highlight` or\nmodify `:scope`, but `:scope` then refers to the host component’s roots, not to\na directive wrapper.\n\nNames passed to `craftComponent` and `craftDirective` must be unique and match\ntheir declaration names. The dedicated ESLint rules detect missing or\ninconsistent names.\n\n## See Also\n\n- [Encapsulated styles](/guide/components/styles)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [Content projection](/guide/components/content-projection)\n"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"path": "/guide/components/directives",
|
|
114
|
+
"title": "Directives and `.pipe(...)`",
|
|
115
|
+
"body": "# Directives and `.pipe(...)`\n\nA Craft directive decorates **both** a component's logic factory and its\ntemplate — so behaviour and markup travel together, and compose.\n\n**Use one when** the same behaviour must be added to several components:\na tooltip, a highlight, focus management, analytics on interaction.\n**Not when** the behaviour belongs to one component — put it in that component's\nfactory.\n\nDirectives are applied from left to right.\n\n```ts\nimport {\n button,\n craftComponent,\n craftDirective,\n div,\n p,\n type HostRequiredLogic,\n type HostTemplate,\n type Input,\n} from '@craft-ts/component';\n```\n\n## `InteractivePermissions`\n\nThe examples below use a directive that adds a `permissions` object to the\ncomponent context. Its configuration is internal to the directive; the\ncomponent caller only provides the original `user` input.\n\n\n\n\n## Basic composition\n\nA directive transforms the existing logic and template:\n\n```ts\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions);\n```\n\nThe result of `InteractivePermissions` becomes the logic actually executed by\n`Card`:\n\n```text\ncomponent inputs\n ↓\noriginal logic\n ↓\nlogic added by the directive\n ↓\nfinal context\n ↓\nfinal template\n```\n\n## Directive configuration input\n\nA fixed configuration can be supplied when the directive is created:\n\n```ts\nconst hasPermission = (permission: Permission) =>\n craftDirective(\n 'hasPermission',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) => (user: Input<User>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permissions: {\n canAccess: () => user().permissions.includes(permission),\n },\n };\n },\n\n (baseTemplate: HostTemplate<ProvidesPermissions>) => (context) =>\n context.permissions.canAccess() ? baseTemplate(context) : [],\n );\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermission('edit'));\n```\n\n`edit` is internal configuration. The caller of `Card` does not provide it.\n\n## Input supplied by the component caller\n\nA directive can also add a public input to the component:\n\n```ts\nconst hasPermissionInput = craftDirective(\n 'hasPermissionInput',\n {},\n (baseLogic: HostRequiredLogic<RequiresUser>) =>\n (user: Input<User>, permission: Input<Permission>) => {\n const context = baseLogic(user);\n\n return {\n ...context,\n permission,\n permissions: {\n canAccess: () => user().permissions.includes(permission()),\n },\n };\n },\n\n (\n baseTemplate: HostTemplate<{\n user: Input<User>;\n permission: Input<Permission>;\n permissions: {\n canAccess: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canAccess() ? baseTemplate(context) : []),\n);\n\nconst Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(hasPermissionInput);\n\nCard({\n user: () => currentUser,\n permission: () => 'edit',\n});\n```\n\nThe directive adds `permission` to the final logic and to `Card`'s public\nprops. The renderer passes factory arguments in prop order, following the\nexisting convention for functional component factories.\n\n## Structural directive\n\nA structural directive decides whether the template produces nodes:\n\n\n\n\nWhen `when()` becomes false, the renderer removes the template output. When it\nbecomes true again, the template is rendered again.\n\nA structural directive can consume context added by a previous directive:\n\n```ts\nconst onlyEditable = craftDirective(\n 'onlyEditable',\n {},\n (\n baseLogic: HostRequiredLogic<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) => baseLogic,\n\n (\n baseTemplate: HostTemplate<{\n permissions: {\n canEdit: () => boolean;\n };\n }>,\n ) =>\n (context) => (context.permissions.canEdit() ? baseTemplate(context) : []),\n);\n\nconst EditableCard = craftComponent(\n 'EditableCard',\n {},\n (user: Input<User>) => ({ user }),\n ({ user }) => div(user().name),\n).pipe(InteractivePermissions, onlyEditable);\n```\n\nThe context flows from left to right:\n\n```text\noriginal logic\n → InteractivePermissions\n → { user, permissions }\n → onlyEditable\n → template or []\n```\n\n## Directives on elements\n\nA component template can also apply a structural directive to a hyperscript\nnode:\n\n```ts\nconst message = p('Message').pipe(whenDirective);\n```\n\nThe component context is passed to the decorated template. Craft structural\ndirectives can therefore transform Craft output without introducing an\nintermediate component.\n\nFunctional DOM directives can also be applied with `.pipe(...)`. Their declared\ninputs are consumed by the directive instead of becoming DOM attributes:\n\n```ts\nbutton({ craftRouterLink: link }).pipe(CraftRouterLink);\n```\n\nA field configured with `insertSelectFormTree` must be selected before it is\nbound, so its lazy insertions (including validators) are registered:\n\n```ts\ninput({ type: 'email' }).pipe(\n CraftFieldDirective(loginForm.form.selectEmail()),\n);\n```\n\n## Composition rules\n\n- Create a configurable directive with `craftDirective(...)`, then pass it to\n `.pipe(...)`.\n- A directive can add public inputs; they appear in the final component props.\n- A directive placed after another receives the already decorated logic and\n template, so it can consume context added by the previous directive.\n- Generator factories continue to be executed by the Craft runtime. Dependencies\n from both the original and decorated factories remain part of the component\n dependency contract.\n\n## See Also\n\n- [Customization](/guide/components/customization)\n- [Encapsulated styles](/guide/components/styles)\n- [Testing components](/guide/testing/components)\n"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"path": "/guide/components/fine-grained-reactivity",
|
|
119
|
+
"title": "Fine-grained reactivity",
|
|
120
|
+
"body": "# Fine-grained reactivity\n\nCraft templates are reactive at the **binding** level. When a signal changes,\nCraft updates the text node, DOM property, class, style, or host binding that\nread it. It does not need to execute the surrounding component template again.\n\n```ts\n({ counter }) =>\n div([\n h2('Counter'),\n p({ class: 'value' }, counter),\n button({ click: counter.increment }, '+'),\n ]);\n```\n\nHere, `counter` is passed to `p` as a yieldable reader. The renderer drives the\nread for that text binding. Incrementing the counter evaluates that binding and\npatches its text node; the `div`, heading, button, and component template remain\nuntouched.\n\n## The binding is the reactive boundary\n\nA function in a rendered position declares a binding. The callback only reads\nvalues already derived by the primitive layer; comparisons, formatting, and UI\ndecisions stay out of the template:\n\n```ts\np(items.totalLabel);\n\nbutton(\n {\n disabled: items.isEmpty,\n title: items.clearTitle,\n },\n 'Clear',\n);\n\ndiv({\n class: items.emptyClass,\n style: items.emptyStyle,\n});\n```\n\n`totalLabel`, `isEmpty`, `clearTitle`, `emptyClass`, and `emptyStyle` are named\nderived values exposed by the state, query, insertion, or component context.\nPass the reader. If only an item-related dependency changes, Craft evaluates\nonly the affected bindings. A sibling binding depending on another reader does\nnot run.\n\nStatic values do not need callbacks:\n\n```ts\nh2('Shopping cart');\nbutton({ type: 'button' }, 'Clear');\n```\n\n## Do not read reactive values while building the template\n\nA direct read happens while the component constructs its VNodes. It cannot be\nassigned to one precise DOM binding and becomes a structural template\ndependency instead:\n\n```ts\n// Avoid: these reads happen in the component template.\np(items.totalLabel());\nbutton({ disabled: items.isEmpty() }, 'Clear');\ndiv({ class: items.emptyClass() });\n```\n\nMove each read into the binding that consumes it:\n\n```ts\np(items.totalLabel);\nbutton({ disabled: items.isEmpty }, 'Clear');\ndiv({ class: items.emptyClass });\n```\n\nThis is also the rule for component inputs. Pass a yieldable reader directly\nwhen the child must observe a changing value. When the child needs fields of\nan object, explicitly adapt the input with `deepYieldable`:\n\n```ts\nUserCard({ user: selectedUser });\n```\n\nThe reader is lazy: constructing the parent template does not read\n`selectedUser`. Craft installs it as the source of the child's `user` input.\nThe child then decides which granular binding observes it:\n\n```ts\nconst UserCard = craftComponent(\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => h2(user.displayName),\n);\n```\n\nWhen the `h2` binding first evaluates, `yield* user()` invokes the reader,\nwhich reads `selectedUser`. That text binding becomes the signal consumer.\nWhen the selected user changes, only the binding evaluates again and patches\nthe existing `h2`; neither the parent template nor the child component template\nruns again.\n\nReading the input eagerly in the child would move the dependency back to the\ncomponent boundary and is rejected by `require-reactive-template-bindings`:\n\n```ts\n// Avoid: resolving the input while the child template is built.\nh2(craftUse(user()).displayName);\n```\n\n## Structure has its own reactive scopes\n\nBindings update an existing node. Blocks own changes to the shape of the tree:\n\n```ts\nifBlock(\n hasItems,\n () => CartItems({ items: () => items() }),\n () => p('Your cart is empty.'),\n);\n\neach(items, { track: (item) => item.id }, (item) => p(item.name));\n```\n\n`ifBlock`, `each`, `matchBlock.exhaustive`, and `defer` isolate their own\nstructural work. A branch or list can change without making the parent\ncomponent rebuild unrelated siblings. Use these helpers for structure and\nbinding callbacks for values on existing nodes.\n\n### Progressive `each` rendering\n\nSee the dedicated [Progressive `each` rendering](/guide/components/schedule-each)\nguide for the complete usage and trade-offs.\n\n`each` is synchronous by default. For a large collection, opt into frame-based\nbatching on the `each` node itself:\n\n```ts\neach(items, { track: (item) => item.id }, (item) => p(item.name)).pipe(\n scheduleEach({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nUse `frame` when the list should begin appearing quickly while leaving room for\ninput and painting between batches. `enabled: false` restores synchronous\nrendering, regardless of the selected strategy. The first delivery supports\n`sync` and `frame`; `idle` is reserved for a later scheduler implementation.\n\nScheduling improves perceived responsiveness, but it does not reduce the total\nwork needed to create or update the list. For very large or continuously\nscrolling collections, virtualisation is still preferable because it reduces\nthe number of DOM nodes and bindings that exist at once.\n\n## Keep bindings pure and free of logic\n\nA binding reads a value already derived by the primitive layer. It does not\nformat data, make business decisions, or write state:\n\n```ts\n// Correct: the primitive exposes the render-ready reader.\np(cart.formattedTotal);\n\n// Incorrect: rendering changes application state.\np(function* () {\n yield* counter.update((value) => value + 1);\n return yield* counter();\n});\n```\n\nPerform writes from DOM events, outputs, mutations, or explicit business\neffects. Purity makes a binding safe to evaluate whenever one of its\ndependencies changes.\n\n## Enforce the model with ESLint\n\nEnable both renderer rules with type-aware ESLint configuration:\n\n```js\nexport default [\n {\n files: ['**/*.ts'],\n languageOptions: {\n parserOptions: { projectService: true },\n },\n rules: {\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-render-writes': 'error',\n },\n },\n];\n```\n\n- `require-reactive-template-bindings` rejects direct reads of signals,\n Craft values, and component inputs during VNode construction.\n- `no-render-writes` rejects detectable `set`, `update`, and `mutate` calls from\n templates and binding callbacks while allowing event and output handlers.\n\nSee the [ESLint rules reference](/guide/routing/eslint-rules) for the complete\nconfiguration.\n\n## What you should observe\n\nAfter a binding dependency changes:\n\n- the affected DOM value changes;\n- the node keeps its identity;\n- unrelated bindings do not evaluate;\n- the component template does not emit a new `component / update` trace.\n\nThe current template trace reports component and structural renders, not each\nindividual text or property effect. The absence of a component update therefore\nconfirms that the change stayed below the component boundary; a DOM assertion\nconfirms that the expected binding was patched.\n\nEffects are owned by their rendered nodes. Removing a branch, list item, or\ncomponent destroys its binding effects, so their dependencies are released with\nthe DOM they served.\n\n## Migration checklist\n\n1. Move comparisons, formatting, and display decisions into named derived\n primitive values such as `items.isEmpty`.\n2. Pass yieldable readers to text bindings (`p(counter)`), or use a generator\n when the binding must format: `p(function* () { return \\`Count: ${yield* counter()}\\`; })`.\n3. Pass yieldable readers to DOM properties such as `value`, `disabled`, and\n `title`.\n4. Return complete reactive class and style readers from the primitive.\n5. Pass changing component inputs as yieldable readers.\n6. Express structural changes with `ifBlock`, `each`,\n `matchBlock.exhaustive`, or `defer`.\n7. Enable the two ESLint rules and remove every direct reactive template read.\n\nContinue with [Components](/guide/components/) for the complete\n`craftComponent` model or [Observability](/guide/advanced/observability) to\ninspect rendering and correlated interactions.\n"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"path": "/guide/components/pending-block",
|
|
124
|
+
"title": "settledValue & pendingBlock",
|
|
125
|
+
"body": "# settledValue & pendingBlock\n\nReading an async value in a template without ever handling `undefined` — and\nbeing told at **compile time** when the loading state has nowhere to go.\n\n**Use it when** a template renders data that comes from a `query`.\n**Not when** you want to drive the loading state yourself: `query.value()`\n(`T | undefined`) and `query.status()` stay exactly as they were.\n\n## Import\n\n```typescript\nimport { settled } from '@craft-ts/core';\nimport { pendingBlock } from '@craft-ts/component';\n```\n\n## Overview\n\nA resource-like `query`, `mutation` or `asyncProcess` exposes a second read next\nto `value`:\n\n```typescript\nusers.value(); // User[] | undefined — you handle the wait\nusers.settledValue(); // User[] — the wait is handled for you\n```\n\n`settledValue` never returns `undefined` and never returns a value while the\nsource carries an exception. When there is nothing to show it **suspends**: it\nthrows a `CraftNotSettled` that the nearest `pendingBlock` turns into a\nfallback. A business exception throws through the existing channel instead, and\nlands in the nearest `catchBlock`.\n\nBecause the dependency is visible in the types, a template that renders a\nsuspending value with no `pendingBlock` around it does not compile.\n\n## Reading a settled value in a computed\n\nInside a `craftComputed` generator, `yield* settled(ref)` hands back the\nresource's settled read:\n\n```typescript\nconst teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n // `list()` is `User[]` here — never undefined, never in exception\n return () => [...new Set(list().map((user) => user.team))].sort();\n});\n```\n\nNothing is awaited and nothing is yielded at runtime: the markers are type-only.\nWhat they do is tag `teams` as *depending on the async source `users`*, which is\nwhat the template checker reads.\n\n## The boundary\n\nThe boundary is piped onto any node above the reads:\n\n```typescript\ndiv([span(teams), span(total)]).pipe(\n pendingBlock({ fallback: () => p('Chargement…') }),\n);\n```\n\nOne boundary covers every async source in its subtree — the same shape as\n`Suspense`. When each zone deserves its own skeleton, name the sources instead;\nthe list is checked exhaustively, so a source with no fallback (and a fallback\nfor a source that never suspends here) is a compile error:\n\n```typescript\ndiv([...]).pipe(\n pendingBlock.exhaustive({\n users: () => SkeletonList(),\n orders: () => SkeletonRows(),\n }),\n);\n```\n\nThe handler keys are the **query names**, even when the template only ever sees\na computed derived from them.\n\n## What the compiler enforces\n\n```typescript\ncraftComponent(\n 'teamList',\n {},\n function* () {\n const users = yield* query('users', { ... });\n const teams = craftComputed('teams', function* () {\n const list = yield* settled(users);\n return () => list().length;\n });\n return { teams };\n },\n // ERROR_async_source_rendered_outside_a_pendingBlock: \"users\"\n ({ teams }) => div([span(teams)]),\n);\n```\n\n\n\nThe sources bubble up through the node tree exactly like unhandled exception\ncodes do, and the check fires on the `craftComponent` template argument, naming\nthe sources that have nowhere to show their loading state. Several suspending\ncomputeds in one template are all covered by the same rule: every one of them\nneeds a boundary above it.\n\nThe obligation travels through `each`, `ifBlock`, `defer`, projected content and\nnested elements — anywhere a node can carry children.\n\n## Stale-while-revalidate\n\nA reload that keeps its previous value does **not** suspend: the stale value is\nserved while the new one is in flight, so a refetch never blanks a screen that\nalready has data. Only a source with nothing to show suspends. To make a reload\nsuspend again, clear the value with `preservePreviousValue: () => false`.\n\nA refetch throws nothing, so the boundary cannot learn about it from the\nsuspension channel — it watches the source's own status instead. Give a handler\nits `reloading` slot to report it, rendered **next to the still-visible\nsubtree**:\n\n```typescript\npendingBlock.exhaustive({\n issue: {\n pending: () => p('Waiting for an invoice…'),\n reloading: () => p('Re-issuing…'),\n },\n});\n\n// or, for the catch-all form\npendingBlock({ fallback: () => Skeleton(), reloading: () => Spinner() });\n```\n\n## Runtime behaviour\n\nWhile a source is pending, the boundary renders its fallback and detaches the\nsuspended subtree's DOM — **detaches, not destroys**. Keeping it alive is what\nmakes resumption work: the suspended bindings stay subscribed to their source's\nstatus, so they re-run and release the boundary the moment the data arrives.\n\nTwo escapes are reported rather than silently swallowed:\n\n- a settled read that suspends with no boundary above it throws\n `CraftUnhandledPendingError`;\n- a settled read whose source carries an exception with no `catchBlock` above it\n throws `CraftUnhandledExceptionError`.\n\nThe first is the runtime backstop for what the types cannot see — typically a\nsettled read hidden inside a lambda (`() => users.settledValue().name`), where\nthe brand that carries the obligation is lost. Bind the value **by reference**\n(`span(users.settledValue)`, `span(teams)`) to keep the compile-time guarantee.\n\n## Two boundaries, two obligations\n\nA settled read has two exits and each one has its own boundary:\n\n| Exit | Thrown | Boundary | Checked at |\n| ---- | ------ | -------- | ---------- |\n| nothing to show yet | `CraftNotSettled` | `pendingBlock` | `craftComponent(...)` |\n| the source carries an exception | `CraftGenShortCircuit` | `catchBlock` | `craftComponent(...)` |\n\nBoth bubble up the node tree until a boundary clears them, and both fail the\n`craftComponent` template argument when uncovered. A `pendingBlock` is not an\nexception boundary — settled exceptions pass straight through it, and vice\nversa.\n\nThese two throws are intentional CraftTS control flow. The shared\n`isCraftControlFlow(error)` predicate identifies them so observability and\nerror-conversion wrappers can rethrow them without logging or taking an app\nsnapshot. If a pending read escapes its boundary, it becomes\n`CraftUnhandledPendingError`; that is a real template error and remains\nobservable.\n\n```typescript\ndiv([span(summary)])\n .pipe(pendingBlock.exhaustive({ issue: () => Skeleton() }))\n .pipe(catchBlock.exhaustive({ INVOICE_REJECTED: () => Rejected() }));\n```\n\nA `catchBlock` handler receives the exception as `AnyCraftException`: its `code`\nis known, its payload is not. Reach for `matchBlock` when the fallback needs the\npayload itself.\n\n## Current limits\n\n- The by-id forms (`select(...)` / `selectOrCreate(...)`) have no settled read\n yet: a by-id ref holds one status per group member.\n- A component cannot yet delegate its boundaries to its caller: both checks are\n enforced on each `craftComponent` template.\n- A settled read hidden inside a lambda loses its brand, and with it both\n compile-time obligations — the runtime backstops still fire.\n\nThe pending fallback is announced to assistive tech (`aria-live`, `aria-busy`).\nSee [Accessibility](/guide/components/accessibility).\n"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"path": "/guide/components/schedule-each",
|
|
129
|
+
"title": "Progressive rendering with `scheduleEach`",
|
|
130
|
+
"body": "# Progressive rendering with `scheduleEach`\n\n`each` renders synchronously by default. That is the right choice for short\nlists and keeps the initial behavior predictable. For a large collection,\n`scheduleEach` lets Craft spread fragment creation and updates over animation\nframes.\n\n## Basic usage\n\n```ts\nimport { each, scheduleEach } from '@craft-ts/component';\n\neach(cells, { track: (cell) => cell.id }, (cell) => renderCell(cell)).pipe(\n scheduleEach({\n enabled: true,\n strategy: 'frame',\n frameBudgetMs: 4,\n }),\n);\n```\n\nThe directive is attached to the `each` node. It does not add a DOM wrapper and\ndoes not change the `item`, `index`, dependency, exception, or pending-source\ncontracts of the block.\n\n## When to use it\n\nUse `strategy: 'frame'` when the first visible items should appear quickly and\nthe browser must keep handling input and painting while the rest of the list is\ncreated. A smaller `frameBudgetMs` yields more often; a larger budget completes\nthe list sooner but can occupy the main thread for longer.\n\nDisable it explicitly when a screen needs the synchronous behavior:\n\n```ts\neach(items, { track: (item) => item.id }, renderItem).pipe(\n scheduleEach({ enabled: false, strategy: 'frame' }),\n);\n```\n\n`each` without `scheduleEach` is already synchronous. The first delivery\nsupports `sync` and `frame`; `idle` will be added with its fallback policy in a\nlater delivery.\n\n## What scheduling does—and does not do\n\nScheduling improves perceived responsiveness by yielding between batches. It\ndoes not reduce the total work required to create or update every fragment.\nStable keys still control reconciliation, and existing keyed DOM fragments keep\ntheir identity when the collection is reordered.\n\nFor very large or continuously scrolling collections, prefer virtualisation:\nit reduces the number of DOM nodes and bindings that exist at the same time.\nScheduling and virtualisation solve different problems and can eventually be\ncombined.\n\n## Pixel Art Workshop\n\nThe demo's Pixel Art Workshop uses frame scheduling for its 256-cell grid:\n\n```ts\neach(INDEXES, { track: (index) => index }, renderCell).pipe(\n scheduleEach({ strategy: 'frame', frameBudgetMs: 4 }),\n);\n```\n\nThe production benchmark can compare the synchronous baseline and frame mode\nwith 256, 1,000, and 10,000 cells. Its commands and metrics are documented in\n`docs/benchmarks/schedule-each-pixel-art.md` in the repository.\n"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"path": "/guide/components/styles",
|
|
134
|
+
"title": "Encapsulated styles",
|
|
135
|
+
"body": "# Encapsulated styles\n\nStyles declared in `craftComponent(name, meta, factory, template)` are shared by\nevery instance of the component and encapsulated with CSS `@scope`. The registry\nkeeps a single sheet per component and removes it when the last instance is\ndestroyed.\n\n**Use it for** a component's own appearance.\n**Not for** application-wide styles — those belong in your global stylesheet;\nscoping them here just makes them harder to find.\n\n## The common case\n\n```ts\nconst Card = craftComponent(\n 'Card',\n { styles: ':scope { padding: 1rem } .title { font-weight: 700 }' },\n () => ({}),\n () => div([h2({ class: 'title' }, 'Title')]),\n);\n```\n\nThe template root is written `:scope`. Craft adds **no host element and no\nwrapper** — roots carry an internal `data-craft-root` attribute, which you must\nnever set yourself.\n\n## Composing styles from a directive\n\nA directive's styles compose with the component's:\n\n```ts\nconst Highlight = craftDirective(\n 'Highlight',\n { styles: '.highlight { background: yellow }' },\n (baseLogic) => baseLogic,\n (baseTemplate) => (context) => baseTemplate(context, { class: 'highlight' }),\n);\n```\n\n## Pitfalls\n\n**`@scope` adds no specificity.** Adopted sheets are ordered after the document's\nsheets, and the `<style>` fallback is inserted in the `head`. Nested-scope\nproximity can therefore change the cascade compared with a global stylesheet —\nif a rule stops winning after you scope it, this is why.\n\n**Sibling roots can't see each other.** Multi-root templates are allowed, but\nrelationships between sibling roots (`header + main`, say) are not expressible\nthrough this encapsulation.\n\n**A root that is itself a Craft component carries several tokens**, so the\nenclosing component can reach inside it. This is a known limit of the current\nimplementation.\n\n**Names must be unique.** The `craft-component-name-match` and\n`craft-directive-name-match` rules also check that the name matches the\ndeclaration.\n\n**Hoisted rules are still global.** Craft rejects `@import`, document-root\nselectors, and private at-rules whose names are not prefixed by the component\nscope. A `Spinner` animation is named `@keyframes Spinner-spin`. Component\n`@property` registrations keep their public custom-property name, but that name\nmust belong to the component namespace.\n\n## See Also\n\n- [Customization](/guide/components/customization) — the three layers\n- [Typed CSS variables and design tokens](/guide/components/css-variables)\n- [Directives and `.pipe(...)`](/guide/components/directives)\n"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"path": "/guide/components/template-migrator",
|
|
139
|
+
"title": "Template migrator",
|
|
140
|
+
"body": "# Template migrator\n\nPaste an HTML snippet or a web component from a UI library's documentation. The\nconverter generates the equivalent Craft functional template and the imports it\nneeds from `@craft-ts/component`.\n\n**Use it to** bring markup from outside — a design system's docs, a CodePen, an\nexisting template markup — into Craft's template syntax without transcribing it\nby hand.\n\n<CraftTemplateMigrator />\n\n## What it produces\n\nBy default the result is a callback to paste as the fourth argument of\n`craftComponent(...)`. Fill in a name to generate a complete component instead.\n\nNative HTML tags become the matching helpers (`div`, `button`, `section`, …);\ncustom tags become `customElement('my-element', ...)`.\n\n## Pitfalls\n\n**Interpolations and bindings are preserved as expressions**, not translated.\nAdapt them to your Craft context.\n\n**Control-flow directives are not converted.** Rewrite conditional and repeated\nsections with `ifBlock` or `each`.\n\n## See Also\n\n- [Directives and `.pipe(...)`](/guide/components/directives)\n- [CLI automation](/guide/routing/automation) — codemods for the rest of a migration\n"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"path": "/guide/concepts/choose-primitive",
|
|
144
|
+
"title": "Which primitive should I use?",
|
|
145
|
+
"body": "# Which primitive should I use?\n\nThere are five primitives. They share the same shape — a name, a configuration,\noptional insertions — and differ only in **where the value comes from** and\n**what triggers it**.\n\n## The decision\n\n| Where does the value live? | Use |\n| ---------------------------------------- | ---------------------------------------------- |\n| In memory, you own it | [`state`](/guide/state/local-state) |\n| On a server, read | [`query`](/guide/state/server-state) |\n| On a server, written | [`mutation`](/guide/state/mutations) |\n| In the URL's query string | [`queryParams`](/guide/state/url-state) |\n| Nowhere — it's an action with a lifecycle | [`asyncProcess`](/guide/state/async-process) |\n\n## The same table, by symptom\n\n**\"I need a value the user can change.\"** → `state`. It is the default. Reach\nfor anything else only when the value's home is somewhere other than memory.\n\n**\"I need to display data from an API.\"** → `query`. It re-runs when its\n`params` change and carries `isLoading` / `status` / `exception` for you. Don't\nput a `query` result into a `state` — that's two sources of truth.\n\n**\"I need to send something to an API.\"** → `mutation`. Triggered explicitly\nwith `.mutate(...)`. Connect it back to the read side with\n[`insertReactOnMutation`](/guide/state/react-on-mutation) rather than reloading\nby hand.\n\n**\"This filter should survive a refresh and be shareable.\"** → `queryParams`.\nThe URL becomes the source of truth; your query's `params` read from it.\n\n**\"I need to run an async thing and know if it's running.\"** → `asyncProcess`.\nUse it for operations that are not a server read or write: a file export, a\nshare sheet, a delay, a Web API call.\n\n## Things that are *not* a primitive\n\n- **Derived values** — use `craftComputed` inside an insertion. Craft keeps the\n reader dependency visible in the graph.\n- **Reusable logic across primitives** — that's an\n [insertion](/guide/concepts/insertions), not a primitive.\n- **A group of primitives with a name and a scope** — that's a\n [`craftService`](/guide/app/craft-service).\n\n## What they have in common\n\nWhichever you pick, the mechanics are identical: the name comes first, the\nresult is the primitive reference itself, `yield*` drives it inside any\ncraft generator, and the last argument is an insertion.\n\nThat shared shape is one page: **[Anatomy of a\nprimitive](/guide/concepts/primitive-anatomy)**. Read it once and every\nprimitive page becomes just its own specifics. Advanced patterns that need to\nwrite a primitive from DI — wrappers, registries, WebMCP tools, seeding a\nquery result — use the [injectable runtime\ncontext](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Learn: your first state](/learn/01-first-state)\n- [Insertions](/guide/concepts/insertions)\n"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"path": "/guide/concepts/exceptions",
|
|
149
|
+
"title": "Exceptions as values",
|
|
150
|
+
"body": "# Exceptions as values\n\nA declared failure is a **value you return**, not something you throw. It travels\nthrough types instead of escaping through the stack — so the compiler can see it,\nfollow it, and tell you when nobody handled it.\n\nThat is the whole idea, and it rests on a line most codebases leave blurry:\n\n- an **exception** is a failure you declared, expect, and intend to handle —\n \"this email is taken\", \"the session expired\", \"that id is malformed\";\n- an **error** is everything else — the unexpected kind, which should surface\n loudly rather than be silently absorbed.\n\nA `try/catch` tells you nothing about what it might catch. A returned\n`craftException` carries its code and payload all the way to whoever handles it,\nand the set of reachable codes is a **type** — which is what makes exhaustive\nchecking possible at all.\n\n## Declaring one\n\n```typescript\nimport { craftException } from '@craft-ts/core';\n\ncraftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title });\n```\n\nThe first argument carries the `code` (and an optional `scope`); the second is a\nfree-form **payload**, whose type flows all the way to whoever handles it.\n\n## Where they come from\n\nAn exception is a **returned value**, not a thrown one. Return it from the place\nthat detects the failure and the rest of the pipeline stops on its own:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n // rejected before any request is sent — the loader never runs\n method: (payload: { title: string }) =>\n payload.title.trim().length === 0\n ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title })\n : payload,\n\n loader: function* ({ params }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/tasks',\n body: params,\n success: response<Task>(),\n // recognised from the response\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) return;\n return craftException({ _tag: 'TITLE_ALREADY_EXISTS' });\n },\n ],\n }));\n },\n});\n```\n\nGuards, matchers and resolvers raise them the same way — see\n[Route guards](/guide/routing/guards).\n\n## Propagating through a shared utility\n\nThe interesting case isn't one primitive failing — it's a rule that lives in one\nplace and travels. Wrap it in a [`craftGen`](/guide/concepts/generators) and it\nbecomes a reusable unit that **short-circuits its callers**:\n\n```typescript\nimport { craftException, craftGen, craftUntilSettled } from '@craft-ts/core';\n\n// one business rule, declared once\nexport const loadReport = craftGen(function* () {\n const reportRef = yield* Report();\n const report = yield* craftUntilSettled(reportRef);\n\n return report.totalUsers === 0\n ? craftException({ _tag: 'REPORT_EMPTY' })\n : report;\n});\n```\n\nConsumers just `yield*` it. If the rule rejects, everything after the yield is\nskipped — no `if (result.isError)` at each level:\n\n```typescript\nconst { ReportFacade } = craftService(\n { name: 'ReportFacade', providedIn: 'global' },\n function* () {\n const report = yield* loadReport(); // narrowed: never the exception\n return { total: report.totalUsers };\n },\n);\n```\n\n`report` is the success value only. The exception left through the generator\nchannel, and — this is the point — **`REPORT_EMPTY` is now part of\n`ReportFacade`'s reachable codes.** It keeps travelling up until someone deals\nwith it.\n\n### Stopping the propagation\n\nTwo ways, and the difference matters:\n\n**Recover locally** with `catchTag`, and the code **leaves the union** — nobody\nupstream has to know about it:\n\n```typescript\nresolve: craftResolve(function* () {\n return yield* loadReport().pipe(\n catchTag('REPORT_EMPTY', function* () {\n return { totalUsers: 0, generatedAt: null };\n }),\n );\n});\n```\n\n**Let it reach the route**, and `handleExceptions` must have a handler for it —\nthe compiler says so. That's the right choice when the failure should change what\nthe user sees, rather than being papered over with a default value.\n\n::: tip Composition rule\nWhen several utilities are composed, the **first exception wins** — the rest of\nthe program doesn't run. See [Program\noperators](/guide/advanced/program-operators) for `catchTag` and `retry`.\n:::\n\nWorking example: the `slow-page` demo raises `REPORT_EMPTY` from a `craftGen`\nresolver and recovers it locally, so the route never declares a handler for it —\n[slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts).\n\n## Reading them\n\nEvery async primitive exposes its exceptions **split by origin**, and typed from\nthe codes you declared:\n\n```typescript\ncreateTask.hasException(); // boolean\n\ncreateTask.exceptions().params?.TITLE_REQUIRED; // rejected by `method`\ncreateTask.exceptions().loader?.TITLE_ALREADY_EXISTS; // produced by the request\n```\n\nThe origin matters: `params` means nothing left the browser, `loader` means the\nserver was involved. The union is closed, so the compiler knows\n`TITLE_ALREADY_EXISTS` exists and that `TITLE_TOO_LONG` doesn't.\n\n`queryParams` follows the same shape with a `parse` origin for decode failures.\n\n## Handling them\n\nWhere you handle an exception depends on how far it needs to travel:\n\n| The failure concerns… | Handle it… |\n| --------------------------- | ------------------------------------------------------------------------ |\n| One primitive's own UI | Read `exceptions()` where you render it |\n| A form's submission | [`insertFormSubmit`](/guide/forms/submit) — reshape the mutation's codes |\n| Whether a route can render | [Route exception handling](/guide/routing/exception-handling) |\n| Nothing in particular | Let it be an error — the global error component catches it |\n\n## An unhandled exception doesn't just disappear\n\nThis is the rule that ties the whole system together, and it is easy to miss.\n\nWhen a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's **initialization\nexceptions**. It stays attached to the component until something handles it.\n\nMost of the time what you want is a **fallback to render**, which is\n`catchBlock.exhaustive`:\n\n```typescript\nconst Restricted = MyRestrictedComponent.pipe(\n withProviders([provideRestrictedData(/* … */)]),\n catchBlock.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\nHere the failure comes from a provider, before the template exists — so there is\nno source block to preserve and the fallback renders alone. When the source\n*does* exist and should stay visible, use the object form:\n\n```typescript\ncatchBlock.exhaustive({\n NO_ACCESS: { render: () => p('Restricted'), showSource: true, position: 'after' },\n});\n```\n\nReach for `catchTag.exhaustive` only when the reaction is **logic** and produces\nno DOM — logging it, notifying a service:\n\n```typescript\ncatchTag.exhaustive({\n NO_ACCESS: function* () {\n yield* ToastService.show(() => 'No access');\n },\n});\n```\n\nEither way, handling a code at the component **removes it** from the component's\ncontract and from the route's. Whatever you don't handle is **residual**, and it flows up\ninto the route's exception union — where `handleExceptions` must cover it:\n\n```\ncomponent factory + providers\n ↓ (codes not handled by .pipe)\n residual exceptions\n ↓\nroute exception union ── handleExceptions must be exact\n```\n\nAt the route, the check is exhaustive **in both directions**: a reachable code\nwith no handler is a type error, and a handler for a code nothing can produce is\na type error too.\n\n::: warning Where the compile error actually appears\nToday the enforcement is at the **route**, not at the component. The variadic\ncomponent `.pipe(...)` overload is deliberately kept permissive to avoid\nexcessive TypeScript instantiation depth, so an unhandled code there is rejected\nby **runtime** dispatch rather than by the compiler. The compile-time proof is\n[`assertExhaustiveRouteExceptions(routes)`](/guide/routing/exception-handling#exhaustiveness).\n\nPractical consequence: a component rendered outside any route — in a test, or\nnested inside another component — gets no compile-time reminder. Handle its\ncodes explicitly.\n:::\n\nThree utilities do the handling, and which one you want depends on whether the\nresult is logic or DOM:\n\n| Utility | Handles in | Produces |\n| ----------------------- | ---------- | --------------------------------------------- |\n| `catchBlock.exhaustive` | template | a fallback around a source block — **the default choice** |\n| `matchBlock.exhaustive` | template | a fallback rendered from an exception value or signal |\n| `catchTag.exhaustive` | logic | nothing renderable — call a service, log, … |\n\nDetails on [Customization](/guide/components/customization#choosing-an-exception-utility).\n\n## Why exhaustiveness is worth the ceremony\n\nBecause the set of reachable codes is a **type**, the compiler can compare it\nagainst the set you handled. At the route level that comparison is an assertion\nyou place once per collection:\n\n```typescript\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\nA code that can be produced but isn't handled is a compile error. So is a\nhandler for a code nothing produces. Add a `craftException` to a guard six months\nfrom now and the routes file tells you exactly which routes must decide what to\ndo about it.\n\nThe assertion itself is an unused call unless it stays in the file.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\ncollection omits it.\n\n## Pitfalls\n\n**Throwing instead of returning.** A thrown value is an *error*: it bypasses the\ntyped union and lands in the global error path. Return the `craftException`.\n\n**Reusing one code for two meanings.** The code is the identity the handlers\nmatch on. Two different failures deserve two codes, with payloads carrying the\ndetail.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n- [query](/guide/state/server-state) — typed HTTP exception matchers\n- [Form exception handling](/guide/forms/exceptions)\n"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"path": "/guide/concepts/generators",
|
|
154
|
+
"title": "Generators and `yield*`",
|
|
155
|
+
"body": "# Generators and `yield*`\n\n`yield*` is the one mechanism the whole library rests on. This page explains what\nit actually does, then covers `craftGen`, which lets you write a tracked\ngenerator outside a service.\n\n## Why a generator at all\n\nDependencies hidden in a runtime container are invisible from the outside:\nnothing in the consumer's type says they exist. The compiler can't catch a\nmissing provider, and a test can't tell you what to mock.\n\nA generator gives the runtime a channel. Each `yield*` reports \"I need this\",\nthe driver resolves it, and the dependency is recorded **in the type**:\n\n```typescript\nconst { TaskList } = craftService(\n { name: 'TaskList', providedIn: 'function' },\n function* () {\n const api = yield* TaskApi(); // tracked\n const tasks = yield* state('tasks', []); // tracked\n return tasks;\n },\n);\n```\n\nEverything downstream reads that type: the route DI check, the testing register,\nthe dependency snapshot.\n\n## The rule\n\n> Every named entity yields what it does not own. A factory, a `craftComputed`,\n> a `craftMethod`, a generator insertion member — each one records **its**\n> dependencies with `yield*`.\n\nOwning means: the primitive internals handed to **this** insertion\n(`state`, `set`, `update`, `patch`). Everything else — another primitive, a\nservice, a sibling method, an input, a nested resource reader — is yielded.\n\n```typescript\nconst counter = yield* state('counter', 0, ({ state, update }) => ({\n increment: () => update((value) => value + 1),\n doubled: craftComputed(function* () {\n return (yield* state()) * 2;\n }),\n}));\n\nconst stats = craftComputed('stats', function* () {\n return (yield* counter()) + (yield* counter.doubled());\n});\n```\n\n`increment` may return `update(...)` directly: it is not a generator, and the\ninsertion wrapper consumes the write. `doubled` does not own `state()`, so it\nyields it. `stats` does not own `counter`, so it yields both readers.\n\nIn a Craft template, pass the reader or the method instead of wrapping a\nsynchronous call:\n\n```typescript\np(counter);\nbutton({ click: counter.increment }, '+');\n```\n\n`craftUse(...)` is the synchronous boundary: when there is no generator to yield\nfrom, a field or callback can drive the primitive with it instead. That is the\nend of the graph, which is why `craftUse` has nothing to track. Use it in tests\nand other synchronous boundaries too:\n`craftUse(counter.increment())`.\n\nYield only what you use: `yield* TaskApi.fetchAll()` records one property\ninstead of the whole service, which is what keeps test registers small. See\n[Shaping the public API](/guide/app/expose-api).\n\nThe `craft-ts/require-yieldable-reactive-read`,\n`craft-ts/require-yieldable-insertion-write` and\n`craft-ts/require-yieldable-template-method` ESLint rules enforce this. See\n[ESLint rules](/guide/routing/eslint-rules).\n\n## `craftGen` — a tracked generator outside a service\n\nBuild reusable generator factories that can be composed with `yield*` and that\nshort-circuit through typed `craftException` values.\n\n`craftGen(factory)` wraps a generator factory and returns an invoker you delegate\nto with `yield*`. It keeps the inner generator model intact:\n\n- dependency yields still flow to the outer driver;\n- the success value is returned through `yield*`;\n- `craftException(...)` results are converted into a `CraftGenShortCircuit`;\n- the reachable exception codes remain visible at the type level.\n\nThat makes it the right tool for reusable route logic — role checks, feature\nflags, onboarding gates.\n\n### The common case\n\n```typescript\nimport { craftException, craftGen } from '@craft-ts/core';\n\nexport const roleGuard = craftGen(function* (...roles: Role[]) {\n const { user } = yield* Auth(undefined, ({ user }) => ({ user }));\n const currentUser = yield* user();\n\n if (!currentUser) {\n return craftException({ _tag: 'NOT_AUTHENTICATED' });\n }\n\n return roles.includes(currentUser.role)\n ? true\n : craftException({ _tag: 'FORBIDDEN_ROLE' });\n});\n\nexport const noPizzeriaGuard = craftGen(function* () {\n const { pizzeria } = yield* Auth(undefined, ({ pizzeria }) => ({ pizzeria }));\n\n return (yield* pizzeria())\n ? craftException({ _tag: 'HAS_PIZZERIA' })\n : true;\n});\n```\n\nUsed from a route:\n\n```typescript\ncanActivate: function* () {\n yield* roleGuard(ROLES.PIZZERIA_ADMIN);\n yield* noPizzeriaGuard();\n return true;\n},\n```\n\n### Why it matters\n\nWithout it, reusable guards turn into copy-pasted generator blocks with repeated\nbranching and ad hoc exception handling. `craftGen` lets you:\n\n- parameterise one guard and reuse it across routes;\n- keep the route logic readable by composing with `yield*`;\n- preserve exhaustiveness, because every reachable exception code stays typed;\n- keep route dependency tracking intact, because the yielded dependencies still\n surface to the surrounding route.\n\nIn practice this is the difference between \"a guard that works\" and \"a guard you\ncan safely reuse and evolve\".\n\n### How it behaves\n\n- A normal return value comes back from `yield*` unchanged.\n- A returned `craftException` makes the wrapper throw `CraftGenShortCircuit`.\n- Yielded dependencies are relayed unchanged to the caller.\n- When you compose several guards, the first exception wins.\n\n## Pitfalls\n\n**A primitive invocation is single-use.** Each call produces one generator, to be\nconsumed exactly once — don't store one and `yield*` it twice.\n\n## See Also\n\n- [Route guards](/guide/routing/guards)\n- [ESLint rules](/guide/routing/eslint-rules) — `require-yieldable-reactive-read` and siblings\n- [Program operators](/guide/advanced/program-operators) — `catchTag` and `retry`\n- [Exceptions as values](/guide/concepts/exceptions)\n"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"path": "/guide/concepts/insertion-pipes",
|
|
159
|
+
"title": "Typed insertion pipes",
|
|
160
|
+
"body": "# Typed insertion pipes\n\nEach primitive accepts one insertion directly. When a primitive needs several\ninsertions, use the pipe named after that primitive:\n\n| Primitive | Typed pipe |\n| -------------- | ------------------------ |\n| `state` | `insertStatePipe` |\n| `query` | `insertQueryPipe` |\n| `mutation` | `insertMutationPipe` |\n| `queryParams` | `insertQueryParamsPipe` |\n| `asyncProcess` | `insertAsyncProcessPipe` |\n| `craftStateMachine` | `insertStateMachinePipe` |\n\nThe typed pipe keeps the primitive call readable and gives every member the\ncorrect contextual type. Members run from left to right, and each member can\nread the outputs of the members before it through `insertions`.\n\n## State\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst { counter } =\n yield *\n state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update }) => ({\n increment: () => update((value) => value + 1),\n }),\n ({ state, insertions }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n incrementAndReport: function* () {\n yield* insertions.increment();\n return yield* state();\n },\n }),\n ),\n );\n```\n\n## Query\n\n```typescript\nimport {\n insertStoragePersister,\n insertQueryPipe,\n query,\n} from '@craft-ts/core';\n\nconst { users } =\n yield *\n query(\n 'users',\n {\n params: () => ({ page: 1 }),\n loader: ({ params }) => api.getUsers(params),\n },\n insertQueryPipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'users',\n })),\n ({ resource }) => ({\n reloadUsers: function* () {\n return yield* resource.reload();\n },\n }),\n ),\n );\n```\n\n## Mutation\n\n```typescript\nimport { insertMutationPipe, mutation } from '@craft-ts/core';\n\nconst { saveUser } =\n yield *\n mutation(\n 'saveUser',\n {\n method: (user: User) => user,\n loader: ({ params }) => api.saveUser(params),\n },\n insertMutationPipe(\n ({ resource }) => ({\n reload: function* () {\n return yield* resource.reload();\n },\n }),\n ({ insertions }) => ({\n reloadTwice: function* () {\n yield* insertions.reload();\n yield* insertions.reload();\n },\n }),\n ),\n );\n```\n\n## URL state\n\n```typescript\nimport { craftComputed, insertQueryParamsPipe, queryParams } from '@craft-ts/core';\n\nconst { filters } =\n yield *\n queryParams(\n 'filters',\n {\n state: {\n page: { fallbackValue: 1 },\n search: { fallbackValue: '' },\n },\n },\n insertQueryParamsPipe(\n ({ state }) => ({\n hasSearch: craftComputed(function* () {\n return (yield* state()).search.length > 0;\n }),\n }),\n ({ state, patch }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n }),\n ),\n );\n```\n\n## Async process\n\n```typescript\nimport { insertAsyncProcessPipe, asyncProcess } from '@craft-ts/core';\n\nconst { search } =\n yield *\n asyncProcess(\n 'search',\n {\n method: (term: string) => term,\n loader: ({ params }) => api.search(params),\n },\n insertAsyncProcessPipe(\n () => ({ source: 'search-box' as const }),\n ({ insertions }) => ({\n prefixTerm: (term: string) => `${insertions.source}:${term}`,\n }),\n ),\n );\n```\n\n## When to use `craftPipe`\n\nUse a single insertion directly when there is no composition:\n\n```typescript\nstate('counter', 0, ({ update }) => ({\n increment: () => update((value) => value + 1),\n}));\n```\n\nKeep [`craftPipe`](/guide/concepts/insertions) for universal compositions that\nneed an explicit context, especially nested insertions such as `insertSelect`:\n\n```typescript\nstate('board', initialBoard, (context) =>\n craftPipe(\n context,\n insertSelect('grid', (gridContext) =>\n craftPipe(gridContext, ({ update }) => ({\n reset: () => update(() => []),\n })),\n ),\n ({ state }) => ({\n rowCount: craftComputed(function* () {\n return (yield* state()).grid.length;\n }),\n }),\n ),\n);\n```\n\nThe typed pipes delegate to `craftPipe`, so their runtime semantics remain the\nsame: insertion outputs are merged left to right, generator insertions are\ndriven, and each member keeps its own observability wrapper.\n"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"path": "/guide/concepts/insertions",
|
|
164
|
+
"title": "Insertions",
|
|
165
|
+
"body": "# Insertions\n\nAn insertion is a function that receives a primitive's internals and returns\nwhat to expose on it. It is how behaviour gets attached to state — and how it\ngets reused.\n\n**Use one** whenever a primitive needs methods, computed values, or a ready-made\nbehaviour like storage persistence.\nEvery primitive accepts one insertion directly. For several insertions, prefer\nthe typed helper for that primitive; see\n[Typed insertion pipes](/guide/concepts/insertion-pipes). Use `craftPipe` when\nyou need a universal pipe or an explicit nested context.\n\n## The common case\n\nThe library's insertions and the ones you write are the same shape, so they\ncompose in the same pipe:\n\n```typescript\nimport {\n craftUnique,\n insertStoragePersister,\n insertPaginationPlaceholderData,\n insertReactOnMutation,\n insertQueryPipe,\n insertStatePipe,\n query,\n} from '@craft-ts/core';\n\nconst users = yield* query(\n 'users',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertQueryPipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'users',\n })),\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n insertReactOnMutation(deleteUser, {\n filter: ({ mutationIdentifier, queryResource }) =>\n !!queryResource.value()?.some((u) => u.id === mutationIdentifier),\n optimisticUpdate: ({ queryResource, mutationIdentifier }) =>\n removeOne({\n entities: queryResource.value(),\n id: mutationIdentifier,\n }),\n }),\n ),\n);\n```\n\nThe typed helper supplies the query context to each member and keeps the\nprimitive call free of context plumbing.\n\n::: tip A single insertion needs no pipe\nPass it directly:\n\n```typescript\nconst user = yield* query('user', config, insertStoragePersister({ … }));\n```\n\n:::\n\n## Writing your own\n\nThere is nothing special about a library insertion. Yours is a function of the\nsame shape:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((c) => c + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n```\n\nExtract it to a named function the moment two primitives want the same\nbehaviour — that is the whole extension mechanism.\n\nA member can also be a `function*`, in which case it can `yield*` services and\nthose dependencies fold into the enclosing graph. A `craftComputed` or generator\nmethod must yield every reader it does not own — including this primitive's\n`state()` / `update()` / sibling methods on `insertions`.\n\n## What piping guarantees\n\nPiping is strictly equivalent to attaching members one by one:\n\n- members run **left to right**;\n- each member sees the previous members' outputs on `context.insertions`;\n- the outputs are the **intersection** of all members' — on a key conflict, the\n rightmost wins at runtime;\n- tracked dependencies are the **union** of all members', so `ExtractDeps` sees\n every one;\n- each member is **wrapped individually**, so correlation-id tracking and app\n snapshots observe them separately.\n\n## Nesting\n\nPipes nest freely, including inside `insertSelect` — each level re-passes its\nown context:\n\n```typescript\nconst board = yield* state(\n 'board',\n { ui: { activeColor: 'black' }, grid: createInitialGrid() },\n insertStatePipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'board',\n })),\n () => ({ resetAll$: source$<void>('resetAll$') }),\n insertSelect('grid', (gridContext) =>\n craftPipe(\n gridContext,\n ({ state, update }) => ({\n addRow: () => update((grid) => [...grid, createNextRow(grid)]),\n rowIndexes: craftComputed(function* () {\n return (yield* state()).map((_row, index) => index);\n }),\n }),\n insertSelect('row', ({ update }) => ({\n /* … */\n })),\n ),\n ),\n ),\n);\n```\n\n## Pitfalls\n\n**Choosing the wrong pipe.** Use the primitive-specific helper for a direct\ncomposition. `craftPipe` still requires an explicit context and is the right\nchoice for universal or nested compositions.\n\n**Two members exporting the same key.** The rightmost wins silently at runtime.\nName your outputs so they don't collide.\n\n::: details Why the context is explicit\nIt is what makes one universal pipe possible for all five primitives. The outer\n`(context) => …` lambda is contextually typed *by the primitive*, so TypeScript\nknows the exact context shape before it resolves the `craftPipe` call. Inline\nlambdas keep full contextual typing, higher-order factories like\n`insertReactOnMutation(...)` match as before, and the primitive's `Exceptions`\ninference is never degraded.\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Injectable runtime context](/guide/concepts/primitive-anatomy#injectable-runtime-context) —\n recovering `set` / `update` / `patch` from DI, including for WebMCP\n- [Selecting](/guide/state/select) — `insertSelect` and nested insertions\n- [Reacting to mutations](/guide/state/react-on-mutation)\n"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"path": "/guide/concepts/mental-model",
|
|
169
|
+
"title": "The mental model",
|
|
170
|
+
"body": "# The mental model\n\nThree words describe everything `@craft-ts` does: **declare, yield, derive.**\n\nYou declare state with a name. Every named entity — a factory, a computed, a\nmethod — pulls in with `yield*` what it does not own, so the compiler can see\nthat entity's dependencies. Everything else — validity, loading flags, form\ntrees, error unions — is derived rather than restated.\n\nThis page is the *why*. If you want the *how*, the [Learn\npath](/learn/) walks the same ideas through a working app.\n\n## Declare\n\nState is declared where it is used, close to the component or service that owns\nit, with a name that the tooling can see:\n\n```typescript\nconst counter = yield* state('counter', 0, ({ update }) => ({\n increment: () => update((value) => value + 1),\n}));\n```\n\nThe name isn't a label — it tags the injector (`state:counter`) and is how the\nprimitive shows up in logs, snapshots and observability. Five primitives cover\nevery home a value can have: memory, server (read and write), URL, and async\naction. They share one shape, so learning one teaches the other four — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n\n## Yield\n\nClassic injection hides the dependency graph. `inject(TaskApi)` is invisible from\nthe outside, so the compiler cannot tell you when a provider is missing, and a\ntest cannot tell you what to mock.\n\nYielding makes the same call visible **in the type** of the entity that yielded\nit:\n\n```typescript\nconst api = yield* TaskApi();\n```\n\nA factory that yields `TaskApi` has `TaskApi` in its graph. A computed that\nreads another primitive must yield that primitive too — otherwise the\ndependency is invisible on the computed, even if the surrounding factory\nalready yielded it:\n\n```typescript\nconst doneCount = craftComputed('doneCount', function* () {\n return (yield* tasks()).filter((task) => task.done).length;\n});\n```\n\n`tasks` does not belong to `doneCount`. Closing over `tasks()` would read the\nvalue and skip the graph. Everything downstream — the route DI check, the\ntesting register, the dependency snapshot — reads the type of **that** entity.\n\nAnd because you can yield *part* of a service — `yield* TaskApi.fetchAll()` —\nthe graph records only what you actually used, which is what keeps test setups\nsmall.\n\nThat is the whole trade: one keyword, in exchange for a dependency graph the\ncompiler can check. See [Generators and `yield*`](/guide/concepts/generators).\n\n## Derive\n\nThe third principle is the one that removes the most code: **if a value is a\nfunction of another value, don't store it — derive it.**\n\n- Derived values are `craftComputed`, inside an insertion, and they `yield*`\n the readers they depend on.\n- Loading and error state is derived by the async primitives, not tracked by\n hand.\n- A form's field tree, validity and error types are derived from its state and\n its mutation ([Forms](/guide/forms/)).\n- Route exceptions are derived into a union the compiler forces you to handle\n exhaustively ([Exceptions](/guide/concepts/exceptions)).\n\nThe payoff is that derived things cannot drift out of sync with their source.\nThe cost is that you have to resist keeping a second copy \"just for the\ntemplate\".\n\n## What follows from this\n\n### Composition instead of configuration\n\nBehaviour is added by **insertions** — plain functions that receive a\nprimitive's internals and return what to expose. Storage persistence, optimistic\nupdates and forms are all the same shape as one you'd write yourself. Storage\npersistence uses the backend selected through DI:\n\n```typescript\nconst { myState } = state(\n 'myState',\n 0,\n insertStoragePersister(craftUnique({\n storeName: 'myStore',\n key: 'myState',\n })),\n);\n\nconst { myQuery } = query(\n 'myQuery',\n { params: () => 1, loader: /* … */ },\n insertStoragePersister(craftUnique({\n storeName: 'myStore',\n key: 'myUserQuery',\n })),\n);\n```\n\nCompose several with the primitive-specific helpers in\n[Typed insertion pipes](/guide/concepts/insertion-pipes). Keep\n[`craftPipe`](/guide/concepts/insertions) for universal or nested compositions.\n\n### Methods or events, your choice\n\nA method can be called directly, or bound to a source and driven by an event.\nBoth coexist in the same declaration:\n\n```typescript\nconst resetSource$ = source$<void>('resetSource$');\n\nconst counter = yield* state('counter', 0, ({ set, update }) => ({\n increment: () => update((v) => v + 1), // called\n reset: on$(resetSource$, () => set(0)), // driven by an event, not exposed\n}));\n```\n\nThis is what makes one `resetSource$.emit()` reset several independent states at\nonce, without any of them knowing about the others:\n\n```typescript\nconst search = yield* state('search', '', ({ set }) => ({\n set,\n reset: on$(resetSource$, () => set('')),\n}));\n\nconst page = yield* state('page', 1, ({ set, update }) => ({\n increment: () => update((v) => v + 1),\n reset: on$(resetSource$, () => set(1)),\n}));\n```\n\nSee [`on$`](/guide/reactivity/on).\n\n### Granular state, granular tests\n\nSmall, focused states isolate change. Combined with partial yields, a consumer\ndepends on exactly what it reads — and a test provides exactly that, no more.\n\n### Services as functions\n\nA service is a factory with a name and a scope, not a class:\n[`craftService`](/guide/app/craft-service) for the ones you write,\nand small host adapters for dependencies owned by the runtime. Both participate\nin the same typed composition and the same testing workflow.\n\n```typescript\nconst { UserProfile } = craftService(\n { name: 'UserProfile', providedIn: 'global' },\n function* () {\n const api = yield* UserApi();\n const userId = yield* state('userId', '5', ({ set }) => ({ set }));\n\n const updateEmail = yield* mutation('updateEmail', {\n method: (payload: { id: string; email: string }) => payload,\n loader: function* ({ params }) {\n return yield* api.updateEmail(params);\n },\n });\n\n const user = yield* query(\n 'user',\n {\n params: userId,\n loader: function* ({ params }) {\n return yield* api.getUser(params);\n },\n },\n insertReactOnMutation(updateEmail, {\n optimisticPatch: { email: ({ mutationParams }) => mutationParams.email },\n reload: { onMutationException: true },\n }),\n );\n\n return { userId, user, updateEmail };\n },\n);\n```\n\n### Signals, not RxJS\n\n100% signal-based. RxJS is optional and only appears where you ask for it.\n\n### Declarative code is legible code\n\nThe three principles add up to something that is rarely stated outright: the app\nbecomes **declared data** rather than control flow to be reconstructed. Two\nconsequences follow, and both are worth more than they look.\n\n**Observability stops being instrumentation.** Because every dependency\nresolution and every crafted function passes through one system, that system is\nwhere you wrap them — structured logs through a yieldable `Console`, correlation\nids across the graph, per-service timing, snapshots of the live dependency\ntree — with no change to business code. Retrofitting the same thing onto\nimperative code means touching every call site. See\n[Observability](/guide/advanced/observability).\n\n**And what a tool can read, a tool can help with.** The dependency graph, the\nreachable exceptions and the route contract are all declared, so an agent — or a\nfuture WebMCP-style integration — can reason about the app without inferring it\nfrom execution. The same property that makes the compiler able to check your\nproviders makes the codebase tractable to something that isn't you.\n\n### Exceptions are values, errors are surprises\n\nA craft *exception* is a failure you declared and expect to handle; an *error*\nis the unexpected kind. Keeping them apart is what allows the compiler to check\nthat you handled every declared case.\n\nThis is **error-as-value**: a declared failure is *returned*, not thrown, so it\npropagates through types rather than escaping through the stack. A `try/catch`\ntells you nothing about what it might catch; a returned `craftException` carries\nits code and payload all the way to whoever handles it — and the compiler knows\nif nobody does. See [Exceptions as values](/guide/concepts/exceptions).\n\n## See Also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Learn: the guided path](/learn/)\n"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"path": "/guide/concepts/primitive-anatomy",
|
|
174
|
+
"title": "Anatomy of a primitive",
|
|
175
|
+
"body": "# Anatomy of a primitive\n\nThe five primitives — `state`, `query`, `mutation`, `queryParams`,\n`asyncProcess` — share one shape. Learn it once here; each primitive's page then\nonly covers what is specific to it.\n\n## The shape\n\n```typescript\nprimitive(name, config, insertion?);\n```\n\n- **`name`** — always first, always a string literal.\n- **`config`** — what the primitive needs to do its job (an initial value, a\n loader, a codec map…). This is the part that differs between primitives.\n- **`insertion`** — optional, adds methods and computed values to the result.\n\n## Naming is not decoration\n\nThe name tags the primitive's injector — `state:tasks`, `query:userQuery` — and\nis what identifies it in logs, snapshots and the observability tooling. Two\nprimitives with the same name in the same scope are two different things wearing\none label, and the tooling cannot tell them apart.\n\n## Driving it with `yield*`\n\nA primitive does not run itself. Inside any generator host — a `craftComponent`\nlogic factory, a `craftService` factory, a `craftComputed`, a `craftMethod`,\n`craftGen`, a route helper — `yield*` is the driver. The entity that yields\nrecords the dependency on **its** graph:\n\n```typescript\nconst tasks = yield* state('tasks', []);\n```\n\n`yield*` also folds whatever the primitive depends on into the enclosing\ndependency tree, which is what the route DI check and the test registers read.\n\n## It resolves to the primitive reference\n\nEvery named primitive returns its reference directly:\n\n```typescript\nconst tasks = yield* state('tasks', []);\n```\n\nA factory arrow can return a single primitive directly. `craftService` drives it\nand exposes the primitive reference itself:\n\n```typescript\nconst { MyService } = craftService(\n { name: 'MyService', providedIn: 'global' },\n () => state('counter', 0),\n);\n```\n\nWhen a factory exposes several primitives, wrap the record with\n`craftYieldRecord`. It yields each primitive generator and keeps the record\nkeys in the returned value:\n\n```typescript\nimport {\n craftComputed,\n craftService,\n craftYieldRecord,\n query,\n state,\n type CraftServiceInput,\n} from '@craft-ts/core';\n\nconst { UserQuery } = craftService(\n { name: 'UserQueryWithState', providedIn: 'global' },\n (inputs: { userId: CraftServiceInput<string | undefined> }) =>\n craftYieldRecord({\n userQuery: query('userQuery', {\n params: function* () {\n return yield* inputs.userId();\n },\n loader: ({ params }) => ApiService.getItemById(params),\n }),\n refresh: state('refresh', 0, ({ update }) => ({\n increment: () => update((value) => value + 1),\n })),\n }),\n);\n```\n\nUse the direct return for one primitive and `craftYieldRecord` for a record of\nprimitives. Inside a generator factory, the equivalent explicit form remains\navailable: `const userQuery = yield* query(...)`.\n\n## Insertions add to the result\n\nThe last argument receives the primitive's internals and returns what to expose:\n\n```typescript\nstate('counter', 0, ({ state, update }) => ({\n increment: () => update((value) => value + 1),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n```\n\nCompose several with the primitive-specific helpers described in\n[Typed insertion pipes](/guide/concepts/insertion-pipes). An insertion can also\nbe a `function*`, in which case it can `yield*` services. A derived value or\ngenerator method must yield readers it does not own — including this\nprimitive's `state()` / `update()` when the member is a generator. Keep\n[`craftPipe`](/guide/concepts/insertions) for universal or nested compositions.\n\n## Scoped providers\n\nEvery primitive config accepts `providers`, for dependencies that should be\nscoped to this primitive alone rather than to the whole service:\n\n```typescript\nquery('userQuery', {\n providers: [provideUserApiService()],\n loader: function* () {\n return yield* UserApiService.get();\n },\n});\n```\n\n## Injectable runtime context\n\nEveryday insertions already receive `set`, `update`, and `patch` as arguments.\nKeep using that.\n\nEach primitive also **provides those same writes through Craft DI** on every\ninsertion method. Wrappers, registries, tests, WebMCP tools, and other\nadvanced patterns can recover them without being passed the insertion context\n— for example to seed a query result, patch a mutation value, or drive a\n`state` from a\n[`provideFnWrapper`](/guide/advanced/observability#providefnwrapper).\n\nThat is also the surface a WebMCP client uses to inspect and mutate a live\nprimitive: `get` / `set` / `update` / `patch` on a query result, a `state`, a\nmutation, an `asyncProcess`, or `queryParams`, without editing TypeScript or\nreloading the page.\n\nThe helpers return `undefined` outside an insertion-method injection context.\nUse the one that matches the primitive, or the generic helper and branch on\n`kind`:\n\n| Primitive | Helper |\n| -------------- | ------------------------------------------- |\n| `state` | `injectStateMethodRuntimeContext()` |\n| `query` | `injectQueryMethodRuntimeContext()` |\n| `mutation` | `injectMutationMethodRuntimeContext()` |\n| `queryParams` | `injectQueryParamsMethodRuntimeContext()` |\n| `asyncProcess` | `injectAsyncProcessMethodRuntimeContext()` |\n| any of them | `injectPrimitiveMethodRuntimeContext()` |\n\nThe context is the same shape everywhere:\n\n```typescript\n{\n kind: 'state' | 'query' | 'mutation' | 'queryParams' | 'asyncProcess';\n get(): unknown;\n set(value: unknown): unknown;\n update(updater: (current: unknown) => unknown): unknown;\n patch(updater: (current: unknown) => object): unknown;\n originalSource: string;\n}\n```\n\n`patch` merges objects. Use `update` to replace arrays or primitives. Nested\n[`insertSelect`](/guide/state/select) methods receive the selected slice, not\nthe root.\n\n```typescript\nimport {\n injectQueryMethodRuntimeContext,\n provideFnWrapper,\n} from '@craft-ts/core';\n\nprovideFnWrapper(\n 'Warning: dependency injection here is not type-safe and may fail at runtime',\n function* (factory, thisArg, args) {\n const query = injectQueryMethodRuntimeContext();\n const result = yield* factory.apply(thisArg, args);\n query?.patch((current) => ({ ...current, viewed: true }));\n return result;\n },\n);\n```\n\n`query`, `mutation`, `asyncProcess`, and `queryParams` also publish the\n**primitive value itself** — not only its methods — through\n`providePrimitiveResourceRuntimeObserver`. Register it on the primitive's\n`providers` (or higher). The observer runs at creation; keep the context if\nyou need to write later. Grouped resources take an optional `id` equivalent to\n`.select(id)`. `state` has no resource observer: only the method context.\n\n```typescript\nimport {\n providePrimitiveResourceRuntimeObserver,\n query,\n type PrimitiveResourceRuntimeContext,\n} from '@craft-ts/core';\n\nlet usersRuntime: PrimitiveResourceRuntimeContext | undefined;\n\nconst users = yield* query('users', {\n providers: [\n providePrimitiveResourceRuntimeObserver((context) => {\n if (context.kind === 'query') {\n usersRuntime = context;\n }\n }),\n ],\n params: () => true,\n loader: function* () {\n return yield* UserApi.list();\n },\n});\n\nusersRuntime?.set([{ id: 'stub', name: 'Preview' }]);\n```\n\nThe internal token behind these helpers is not part of the public API. Use the\nhelpers; do not look up the token yourself.\n\n## Reading a value that may have failed\n\nThe async primitives (`query`, `mutation`, `asyncProcess`) expose one value reader:\n\n- `value()` — never throws, returns `undefined` when no value is available.\n\n::: tip\nPass `query.value` to a template binding. Inside a generator, `yield* query.value()`.\n:::\n\n## Pitfalls\n\n**A primitive invocation is single-use.** Each call produces one generator, to be\nconsumed exactly once. Storing one and `yield*`-ing it twice does not give you\ntwo primitives — it fails.\n\n**It must run in an injection context.** A field initialiser, a constructor, a\ncraft factory. Called outside one, a primitive returns only its configuration\nunder `_config` instead of a live ref — which usually surfaces later as a\nconfusing \"not a function\" error.\n\n**Methods bound to a source with `on$` are not exposed on the result.** They\nwork internally, driven by the source, and do not appear on the ref.\n\n**Don't inject the runtime context from feature insertions.** The insertion\nalready receives typed `set` / `update` / `patch` as arguments. The injectable\nhelpers are untyped and exist for wrappers, registries, WebMCP tools, and\nother advanced patterns.\n\n## See Also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Insertions](/guide/concepts/insertions)\n- [Generators and `yield*`](/guide/concepts/generators)\n- [Observability](/guide/advanced/observability) — `provideFnWrapper` as a\n consumer of the runtime context\n"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"path": "/guide/forms",
|
|
179
|
+
"title": "Forms",
|
|
180
|
+
"body": "# Forms\n\nThere is no `FormBuilder` here. **A form is derived from a state** — its field\ntree, its validity and its error types are all consequences of that state and of\nthe mutation it submits to, so they cannot drift apart from them.\n\n**Use it when** you collect input that needs validation and a typed submission.\n**Not when** a single input maps to a single state — a plain\n[`state`](/guide/state/local-state) with a `set` is enough.\n\n::: tip Start with the guided version\n[Learn step 8](/learn/08-forms) builds a small form end to end before you dig\ninto the individual insertions.\n:::\n\n## Why it is shaped this way\n\nThree pillars, all of which follow from deriving rather than declaring:\n\n1. **Form Insertions** - Modular composition to tackle logic complexity\n2. **Type-safe errors** - Synchronous and asynchronous validation with type-safe exceptions (inferred from validators and submit handler)\n3. **Parallel Forms** - Support for multiple forms in the same state with automatic scoping\n\nAll of this is possible because the logic is entirely derived from the state.\n\n## Form Insertions\n\nForm insertions enable modular composition of functionality:\n\n### insertForm\n\nThe primary insertion that derives a typed form from a primitive.\n\n```ts\nimport { craftUse, state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertNoopTypingAnchor,\n insertSelectFormTree,\n cRequired,\n cEmail,\n} from '@craft-ts/core';\n\nconst userFormState = craftUse(\n state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor, // TS limitation\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n })),\n ),\n ),\n ),\n);\n\nconst form = userFormState.form;\nconst nameField = form.selectName();\nconst emailField = form.selectEmail();\n```\n\n> Note: It only works with the `state` primitive from now.\n\n> `insertNoopTypingAnchor` is a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...)\n\n### insertFormAttributes\n\nAdds attributes and validators to a form field.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n disable: () => isLoading(),\n hidden: () => !showField(),\n })),\n ),\n ),\n ),\n);\n\n// Access email field and its exceptions\nconst form = formState.form;\nconst emailField = form.selectEmail();\nconst errors = emailField()().exceptions.list; // fully typed list of exceptions\nconst emailError = emailField()().exceptions.byValidator['cEmail'];\n```\n\n### Bind a field to the DOM\n\n`CraftFieldDirective` is the DOM adapter for a `CraftField`. It binds the field\nin both directions, marks it touched on blur, and reflects field state through\nnative attributes and `craft-*` CSS classes.\n\nIn a Craft template, apply the functional directive to the concrete node:\n\n```ts\nimport { CraftFieldDirective } from '@craft-ts/core';\n\ninput({\n type: 'email',\n}).pipe(CraftFieldDirective(loginForm.form.selectEmail()));\n```\n\n`insertSelectFormTree` materializes its branch lazily. When validators or other\ninsertions are attached through it, bind the field returned by `selectEmail()`\n(or the corresponding `selectXxx()` method). Binding the raw\n`loginForm.form.email` field bypasses that materialization, so those insertions\nare not registered.\n\nThe directive supports text inputs and textareas, numeric and temporal inputs,\ncheckboxes, radio groups and selects. Validators also project native constraints\nsuch as `required`, `min`, `max`, `minlength` and `maxlength`.\n\nFor a custom control, provide `CRAFT_FIELD_VALUE_CONTROL` or\n`CRAFT_FIELD_CHECKBOX_CONTROL` on the component root. Native Craft nodes use the\nfunctional directive directly.\n\n### Render validation exceptions exhaustively\n\n`fieldExceptionBlock.exhaustive` turns validation cases carried by\n`CraftFieldDirective` or exposed by the component logic into compile-time UI\nobligations. Every reachable code must have one handler, and an unreachable\nhandler is also rejected.\n\n```ts\nimport { fieldExceptionBlock, input, p } from '@craft-ts/component';\n\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldExceptionBlock.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n );\n```\n\nThe field stays mounted and invalid while a message is visible. The block adds\nand merges `aria-invalid` and `aria-describedby`; it does not throw an\nexception or feed route `handleExceptions`.\n\nUse `fieldExceptionBlock.partial` when only some codes belong near the field.\nHandled codes are removed from its contract and the remaining codes continue\nto the next field-exception boundary:\n\n```ts\ninput({ id: 'password', type: 'password' })\n .pipe(CraftFieldDirective(loginForm.form.selectPassword()))\n .pipe(\n fieldExceptionBlock.partial({\n required: () => p('Password is required.'),\n }),\n );\n```\n\nHere `password.required` is handled locally, while `password.minLength` must\nstill be handled by an enclosing `partial` or `exhaustive` block. A partial\nblock may omit reachable codes, but an unreachable handler remains a TypeScript\nerror.\n\nAt a component boundary, group handlers by static field path. Identical codes\non different fields remain separate obligations:\n\n```ts\nconst SafeLoginForm = BaseLoginForm.pipe(\n fieldExceptionBlock.exhaustive({\n email: {\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n },\n password: {\n required: () => p('Password is required.'),\n minLength: ({ exception }) =>\n p(`Use at least ${exception.payload} characters.`),\n },\n }),\n);\n```\n\nObject branches may also carry group or cross-field validators. Materialize the\nbranch in the component logic and return it from the factory:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts cases, for example `credentials.passwordMismatch`, are part of the\ncomponent contract even when the group itself is not passed to\n`CraftFieldDirective`. Handle the grouped path on an enclosing template VNode\nor with `BaseComponent.pipe(fieldExceptionBlock.exhaustive(...))`. If it remains\nunhandled, rendering, mounting, and `loadCraftComponent` reject the component\nat compile time. See [Form exception handling](/guide/forms/exceptions) for the\ncomplete group example.\n\nBy default the block reads the field's `visibleExceptions` directly. The form\nowns that visibility policy; the default is touched or submitted:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only that field's visible exceptions are rendered. A submit\nattempt reveals the remaining exceptions for every field. Available states are\n`dirty`, `touched`, and `submitted`; a block can override\nthe inherited policy with `visibility: 'always'`, another `anyOf` combination,\nor a predicate. `mode` is `first` (validator order) or `all`, and `position` is\n`before` or `after`. Resetting the form clears dirty, touched, and submitted,\nso inherited messages are hidden again.\n\nCustom and async validators participate through their declared exception\nunion exactly like built-ins: their codes must be handled even when the current\nvisibility policy hides them.\n\n### insertFormSchema\n\nAdds a form-level `StandardSchemaV1` validator. Issues are projected onto the\nmatching fields by their schema path, while root and unmaterialized issues stay\navailable through `schemaExceptions()`.\n\n```ts\nconst formState = craftUse(\n state(\n 'formState',\n { email: '' },\n insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)),\n ),\n);\nconst form = formState.form;\n\nform.email.errors();\nform.hasSchemaExceptions();\nform.schemaExceptions();\n```\n\nThe form keeps the schema input value. Schema transformations belong at the\nsubmit boundary, for example through the mutation's `methodSchema`.\n\n### insertFormSubmit\n\n`insertFormSubmit` connects the form to a mutation. It submits only validated\nform values and exposes the mutation's loading and typed exception state on the\nform.\n\nSee [Submitting a form](/guide/forms/submit) for the complete submission\nworkflow, including success handling and exception transformations.\n\n## The pages\n\n- **[Validation](/guide/forms/validation)** — built-in, custom and async validators\n- **[Submitting](/guide/forms/submit)** — wiring a form to a mutation, typed submit exceptions\n- **[Nested forms](/guide/forms/nested)** — sub-trees and sub-form fields\n- **[Exception handling](/guide/forms/exceptions)** — reading and shaping form errors\n- **[Complete examples](/guide/forms/examples)** — two forms end to end\n\n## See Also\n\n- [Validators](/guide/forms/validation)\n- [Submitting](/guide/forms/submit)\n- [Learn step 8](/learn/08-forms) — a form built end to end\n"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"path": "/guide/forms/examples",
|
|
184
|
+
"title": "Complete form examples",
|
|
185
|
+
"body": "# Complete form examples\n\nTwo forms end to end, assembling the pieces from the other pages: a flat creation\nform with validation and submission, then a nested one.\n\n**Read this after** [the overview](/guide/forms/) — these examples don't\nintroduce anything new, they show the parts fitting together.\n\n## Creation form with validation\n\n```ts\ninterface User {\n name: string;\n email: string;\n age: number;\n}\n\nconst { createUserMutation } = mutation('createUserMutation', {\n method: (data: ValidatedFormValue<User>) => data,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.update(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n});\n\nconst { userFormState } = state(\n 'userFormState',\n { name: '', email: '', age: 0 } satisfies User,\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n })),\n ),\n insertSelectFormTree(\n 'age',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cMin({ min: 18 })],\n })),\n ),\n insertFormSubmit(createUserMutation),\n ),\n);\n```\n\n## Complex Nested Form\n\nWhen a selected branch needs more than two insertions, compose them with\n`craftPipe`. The common two-insertion case (`insertNoopTypingAnchor` followed by\n`insertFormAttributes`) can be passed directly as the second argument.\n\n```ts\ninterface Address {\n street: string;\n city: string;\n zipCode: string;\n}\n\ninterface User {\n name: string;\n email: string;\n addresses: Address[];\n}\n\nconst { userFormState } = state(\n 'userFormState',\n {\n name: '',\n email: '',\n addresses: [],\n } satisfies User,\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cEmail()],\n })),\n ),\n insertSelectFormTree(\n 'addresses',\n (context) =>\n craftPipe(\n context,\n insertNoopTypingAnchor,\n insertSelectFormTree(\n 'street',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'city',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSelectFormTree(\n 'zipCode',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cPattern({ pattern: /^\\d{5}$/ })],\n })),\n ),\n ),\n ),\n insertSelectFormTree(\n 'address',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cMinLength({ minLength: 5 })],\n })),\n ),\n ),\n);\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Validators](/guide/forms/validation)\n- [Nested forms](/guide/forms/nested)\n"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"path": "/guide/forms/exceptions",
|
|
189
|
+
"title": "Form exception handling",
|
|
190
|
+
"body": "# Form exception handling\n\nForm validation exceptions are typed UI obligations. A component must handle\nevery reachable exception in its template or forward the remaining cases to a\ncomponent boundary before it can be rendered, mounted, or used by a route.\n\n**Read this when** you render validation messages, split them between several\nlocations, or validate a group of fields.\n\n## Reading exceptions as values\n\nValidators do not throw. They keep the field and form invalid and expose their\nexceptions as signals:\n\n```ts\nconst email = loginForm.form.selectEmail();\n\nemail.errors();\nemail.exceptions().list;\nemail.exceptions().byValidator.cRequired;\nemail.firstLeftFailedValidation();\nemail.lastRightFailedValidation();\n```\n\nHandling an exception only renders its message. It does not remove the\nexception or make the field valid.\n\n## Case 1: handle every exception beside one field\n\n`CraftFieldDirective` carries the field's exact validator cases onto the VNode.\nAn exhaustive block must provide exactly one handler for every reachable code:\n\n```ts\ninput({ id: 'email', type: 'email' })\n .pipe(CraftFieldDirective(loginForm.form.selectEmail()))\n .pipe(\n fieldExceptionBlock.exhaustive({\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n }),\n );\n```\n\nA missing handler and an unreachable extra handler are both TypeScript errors.\n\n## Case 2: handle only some exceptions locally\n\nUse `partial` when an exception belongs beside the control while the remaining\ncases should continue to an enclosing boundary:\n\n```ts\ninput({ id: 'password', type: 'password' })\n .pipe(CraftFieldDirective(loginForm.form.selectPassword()))\n .pipe(\n fieldExceptionBlock.partial({\n required: () => p('Password is required.'),\n }),\n );\n```\n\nIf the field also declares `minLength`, that case remains in the component's\ncontract until another `partial` or `exhaustive` block handles it.\n\n## Case 3: handle several fields at a component boundary\n\nAt a boundary that receives more than one field path, group handlers by their\nstatic path. Identical codes on different fields remain separate obligations:\n\n```ts\nconst SafeLoginForm = BaseLoginForm.pipe(\n fieldExceptionBlock.exhaustive({\n email: {\n required: () => p('Email is required.'),\n email: () => p('Enter a valid email.'),\n },\n password: {\n required: () => p('Password is required.'),\n minLength: ({ exception }) =>\n p(`Use at least ${exception.payload} characters.`),\n },\n }),\n);\n```\n\n## Case 4: handle a group or cross-field validator\n\nA group validator is declared on an object branch rather than on one leaf\ncontrol. Materialize that branch in the component logic and return it from the\nfactory:\n\n```ts\nfunction* registrationLogic() {\n const registration = yield* state(\n 'registration',\n {\n credentials: {\n password: '',\n confirmation: '',\n },\n },\n insertForm(\n insertSelectFormTree(\n 'credentials',\n insertNoopTypingAnchor,\n insertFormAttributes(({ field }) => ({\n validators: [\n cValidate({\n name: 'passwordsMatch',\n validWhen: () =>\n field.value().password === field.value().confirmation,\n exception: () =>\n craftException({ _tag: 'passwordMismatch' }, undefined),\n }),\n ],\n })),\n ),\n ),\n );\n\n const credentials = registration.form.selectCredentials();\n return { registration, credentials };\n}\n```\n\nThe component logic now declares the typed obligation\n`credentials.passwordMismatch`. The group itself does not need a\n`CraftFieldDirective`; only its leaf controls need their usual DOM bindings.\n\n### Handle the group in the template\n\nA grouped handler on an enclosing VNode consumes the logic-level obligation:\n\n```ts\n({ credentials }) =>\n div([\n input({ type: 'password' }).pipe(CraftFieldDirective(credentials.password)),\n input({ type: 'password' }).pipe(\n CraftFieldDirective(credentials.confirmation),\n ),\n ]).pipe(\n fieldExceptionBlock.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n );\n```\n\nThe exception source is registered from the component logic, independently of\na DOM binding for the group.\n\n### Forward the group to the component boundary\n\nThe template may leave the group case unresolved and let the component\nboundary handle it:\n\n```ts\nconst SafeRegistrationForm = BaseRegistrationForm.pipe(\n fieldExceptionBlock.exhaustive({\n credentials: {\n passwordMismatch: () => p('Passwords do not match.'),\n },\n }),\n);\n```\n\nIf neither location handles it, using the component is a compile-time error:\n\n```ts\n// TypeScript error: credentials.passwordMismatch remains unhandled.\nloadCraftComponent(async () => BaseRegistrationForm);\n```\n\n## Visibility: blur and submit\n\nBy default, a block consumes `visibleExceptions`. A validation exception is\nvisible when its field or group is touched, or after a submit attempt:\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n exceptionVisibility: { anyOf: ['touched', 'submitted'] },\n}));\n```\n\nAfter a blur, only the touched field and its parent groups reveal their\nremaining exceptions. A submit attempt reveals every remaining exception in\nthe form. Resetting the form clears `dirty`, `touched`, and `submitted`, so the\nmessages become hidden again.\n\nUse `visibility: 'always'`, another `anyOf` combination, or a predicate to\noverride this policy on one block. `mode` controls whether the first or all\nmatching exceptions render, and `position` selects `before` or `after`.\n\n## Submission and schema exceptions\n\n`insertFormSubmit` exposes submission exceptions separately from field\nvalidation cases. `insertFormSchema` projects issues with paths onto matching\nfields and leaves pathless issues on the form root through\n`schemaExceptions()`.\n\n## See also\n\n- [Validation](/guide/forms/validation)\n- [Nested forms](/guide/forms/nested)\n- [Submitting a form](/guide/forms/submit)\n- [Exceptions as values](/guide/concepts/exceptions)\n"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"path": "/guide/forms/nested",
|
|
194
|
+
"title": "Nested forms",
|
|
195
|
+
"body": "# Nested forms\n\n`insertSelectFormTree` targets a branch of the form, and `insertSubFormField`\ndeclares a sub-form inside it — for state that is not flat.\n\n**Use them when** the state has nested objects or arrays of objects.\n**Not when** the form is one level deep — attach\n[`insertFormAttributes`](/guide/forms/) directly.\n\n## insertSelectFormTree\n\nSelects and composes nested sub-forms.\n\n```ts\ninterface ProductForm {\n name: string;\n variants: Array<{\n color: string;\n stock: number;\n }>;\n}\n\nconst { productFormState } = state(\n 'productFormState',\n { name: '', variants: [] } as ProductForm,\n insertForm(\n insertSelectFormTree(\n 'variant',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({\n validators: [cRequired(), cMin({ min: 0 })],\n })),\n ),\n ),\n);\n\n// Access sub-forms\nconst form = productFormState.form();\nconst variant0 = form.selectVariant(0);\nconst allVariants = form.items();\n```\n\nSelection is lazy: calling `selectVariant(...)`, `items()`, or an object\nselector such as `selectEmail()` materializes the selected branch and registers\nits insertions. Pass that selected field to DOM bindings; accessing the raw\nfield tree alone does not run the branch insertions.\n\nWhen an object branch has a group validator but no matching DOM control,\nmaterialize it in the component logic and return the selected group from the\nfactory instead:\n\n```ts\nconst credentials = registration.form.selectCredentials();\nreturn { registration, credentials };\n```\n\nIts typed validation cases then belong to the component contract without\nrequiring `CraftFieldDirective(credentials)`. Bind the leaf controls and handle\nthe group path on an enclosing `fieldExceptionBlock`. See\n[Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator).\n\n## insertSubFormField\n\nExposes a derived sub-form from a parent value through a lens. This is useful when the form field is not stored as a nested object in the state, but can still be read and written from the parent value.\n\n```ts\nimport { state } from '@craft-ts/core';\nimport {\n insertForm,\n insertFormAttributes,\n insertSubFormField,\n splitLens,\n cRequired,\n} from '@craft-ts/core';\n\nconst { appointmentFormState } = state(\n 'appointmentFormState',\n '2026-05-10 12:00',\n insertForm(\n insertSubFormField(\n 'date',\n splitLens(' ', 0),\n insertFormAttributes(() => ({\n validators: [cRequired()],\n })),\n ),\n insertSubFormField('time', splitLens(' ', 1)),\n ),\n);\n\nconst form = appointmentFormState.form();\nconst dateField = form.selectDate();\nconst timeField = form.selectTime();\n\nconsole.log(dateField.value()); // '2026-05-10'\nconsole.log(timeField.value()); // '12:00'\n\ndateField.set('2026-05-11');\ntimeField.set('09:30');\n\nconsole.log(appointmentFormState()); // '2026-05-11 09:30'\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Validation](/guide/forms/validation)\n"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"path": "/guide/forms/submit",
|
|
199
|
+
"title": "Submitting a form",
|
|
200
|
+
"body": "# Submitting a form\n\n`insertFormSubmit` connects the form to a [mutation](/guide/state/mutations), so\nsubmission gets its loading state, its failure state and — the point — a **typed\nunion of the exceptions submission can produce**, inferred from that mutation.\n\n**Use it when** the form writes somewhere.\n**Reshape the codes** with the `exceptions` pipeline when the server's vocabulary\nisn't the one your UI should show.\n\n```ts\nconst { updateUserMutation } = mutation('updateUserMutation', {\n method: (data: ValidatedFormValue<UserForm>) => data,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.patch(({ response, status }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(409))) {\n return;\n }\n\n return craftException(\n { _tag: 'USER_EMAIL_ALREADY_EXISTS' },\n { message: 'This email is already used' as const },\n );\n },\n ],\n }));\n },\n});\n\nconst { userFormState } = state(\n 'userFormState',\n { name: '', email: '' },\n insertForm(\n insertSelectFormTree(\n 'name',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired()] })),\n ),\n insertSelectFormTree(\n 'email',\n insertNoopTypingAnchor,\n insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),\n ),\n insertFormSubmit(updateUserMutation, {\n success: () => {\n console.log('Form submitted successfully');\n return undefined;\n },\n exceptions: [\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const emailConflict =\n submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS;\n\n if (!emailConflict) return undefined;\n\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n emailConflict.payload,\n );\n },\n ],\n }),\n ),\n);\n\n// Submit the form\nuserFormState.form().submit(); // Automatically triggers the mutation\n\n// Submit exceptions are inferred from the mutation and the `exceptions` rules.\nconst submitErrors = userFormState.form().submitExceptions();\nconst firstSubmitError = submitErrors[0]?.code; // 'EMAIL_NOT_AVAILABLE'\n```\n\n::: warning What `success` is for\n`success` runs inside the **derivation of the submit exception list**, and its\nreturn value is appended to that list. Its purpose is to raise an exception the\nserver reported alongside a successful response — not to run side effects.\nResetting the form, navigating or showing a toast from there mutates state\ninside a computation and re-runs whenever the exceptions recompute. Drive those\nfrom your own code after `submit()`, or from the mutation itself.\n\nThe example above logs from `success` only to show where the hook fires.\n:::\n\n`insertFormSubmit` preserves mutation exceptions by default. Use `exceptions` as\nan ordered pipeline when you want to refine the submit exceptions exposed by the\nform:\n\n```ts\ninsertFormSubmit(updateUserMutation, {\n exceptions: [\n // `omit` autocompletes the exception codes produced by `updateUserMutation`.\n ({ omit }) => omit(['USER_EMAIL_ALREADY_EXISTS']),\n\n // Returning a Craft exception appends it to the current submit exceptions.\n ({ submitCraftResource }) => {\n if (submitCraftResource.exceptions()?.loader?.USER_EMAIL_ALREADY_EXISTS) {\n return craftException(\n { _tag: 'EMAIL_NOT_AVAILABLE' },\n { message: 'This email is already used' as const },\n );\n }\n\n return undefined;\n },\n ],\n});\n```\n\nReturning an array, like `omit(...)`, replaces the current submit exception list.\nReturning a single `craftException(...)` adds it. The final inferred union is\navailable through:\n\n```ts\nconst submitExceptions = userFormState.form().submitExceptions();\nconst aggregatedSubmitExceptions = userFormState.form().exceptions().submit;\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Form exceptions](/guide/forms/exceptions)\n"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"path": "/guide/forms/validation",
|
|
204
|
+
"title": "Validators",
|
|
205
|
+
"body": "# Validators\n\nValidators are declared on a field and produce **typed exceptions** — so the\nerrors a field can raise are known to the compiler, not discovered at runtime.\n\n**Start with the built-ins** below; reach for `cValidate` / `cAsyncValidate`\nwhen a rule is specific to your domain.\n\n@craft-ts provides a complete set of validators with structured exception handling:\n\n## Schema validation\n\nUse `insertFormSchema` when the rules describe the complete form value rather\nthan one field at a time. It accepts any schema compatible with\n`StandardSchemaV1`, including current versions of Zod, Valibot, ArkType and\nEffect Schema — the latter through\n[`Schema.toStandardSchemaV1`](/guide/state/schema-validation#effect-schema).\n\n```ts\nimport { z } from 'zod';\nimport { craftUse, insertForm, insertFormSchema, state } from '@craft-ts/core';\n\nconst userSchema = z.object({\n name: z.string().min(1),\n email: z.string().email(),\n address: z.object({\n zip: z.string().length(5),\n }),\n});\n\nconst userFormState = craftUse(\n state(\n 'userForm',\n {\n name: '',\n email: '',\n address: { zip: '' },\n },\n insertForm(insertFormSchema(userSchema)),\n ),\n);\nconst form = userFormState.form;\n```\n\nIssues with a Standard Schema path are projected onto the matching field:\n\n```ts\nform.email.errors();\nform.address.zip.errors();\nform.schemaExceptions(); // also includes root/unmaterialized issues\n```\n\nIssues without a path remain on the form root. The form is invalid while any\nschema issue exists, so `validatedFormValue()` is `undefined` and\n`insertFormSubmit` does not call its mutation.\n\nSchema validation is synchronous in forms. Use `cAsyncValidate` for an\nasynchronous field rule or an async resource for a server-side check.\n\n### Schema transformations\n\nFollowing the Standard Schema form convention, validation does not replace the\nform's input value with the schema output:\n\n```ts\nconst schema = z.object({\n age: z.string().transform(Number),\n});\n```\n\nThe form keeps `age` as a string. If the submit payload needs the transformed\nnumber, put the same schema on the mutation's `methodSchema`; the mutation\nmethod then receives the parsed output:\n\n```ts\nconst saveUser = mutation('saveUser', {\n methodSchema: schema,\n method: (user) => user, // user.age is number\n loader: saveUserRequest,\n});\n```\n\n`schemaExceptions()` returns typed `SCHEMA_VALIDATION_ERROR` exceptions. Each\nexception contains the original Standard Schema issue and its path in\n`payload.issues`.\n\n## Built-in Validators\n\n### cRequired\n\nChecks that a value is present (not empty).\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n}));\n\n// With condition\ninsertFormAttributes(() => ({\n validators: [cRequired({ when: () => fieldIsRequired() })],\n}));\n```\n\n### cEmail\n\nChecks that a string is a valid email.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cEmail()],\n}));\n```\n\n### cMin / cMax\n\nChecks that a numeric value is within a range.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cMin({ min: 18 }), cMax({ max: 100 })],\n}));\n\n// Dynamic values\ninsertFormAttributes(() => ({\n validators: [cMin({ min: () => minimumValue() })],\n}));\n```\n\n### cMinLength / cMaxLength\n\nChecks the length of a string or collection.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cMinLength({ minLength: 8 }), cMaxLength({ maxLength: 500 })],\n}));\n```\n\n### cPattern\n\nChecks that a string matches a regex pattern.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [cPattern({ pattern: /^\\d{10}$/ })],\n}));\n```\n\n## Custom Validators\n\n### cValidate\n\nCreates a custom synchronous validator.\n\n```ts\ninsertFormAttributes(() => ({\n validators: [\n cValidate({\n name: 'passwordStrength',\n validWhen: () => {\n const pwd = password();\n return pwd.length >= 8 && /[A-Z]/.test(pwd);\n },\n exception: () =>\n craftException(\n { _tag: 'weak-password' },\n {\n message:\n 'Password must contain 8 characters and an uppercase letter',\n },\n ),\n }),\n ],\n}));\n```\n\n### Group and cross-field validation\n\n`insertFormAttributes` can target an object branch as well as a leaf field. Use\nthat branch when one rule depends on several values, such as password and\nconfirmation:\n\n```ts\nfunction* registrationLogic() {\n const registration = yield* state(\n 'registration',\n {\n credentials: {\n password: '',\n confirmation: '',\n },\n },\n insertForm(\n insertSelectFormTree(\n 'credentials',\n insertNoopTypingAnchor,\n insertFormAttributes(({ field }) => ({\n validators: [\n cValidate({\n name: 'passwordsMatch',\n validWhen: () =>\n field.value().password === field.value().confirmation,\n exception: () =>\n craftException({ _tag: 'passwordMismatch' }, undefined),\n }),\n ],\n })),\n ),\n ),\n );\n\n const credentials = registration.form.selectCredentials();\n return { registration, credentials };\n}\n```\n\nCalling `selectCredentials()` materializes the branch insertion. Returning the\nselected group from component logic exposes the typed case\n`credentials.passwordMismatch` to the component contract. It must then be\nhandled in the template or at a component boundary before the component can be\nrendered, mounted, or loaded by a route.\n\nThe group does not need its own DOM control or `CraftFieldDirective`. Bind its\nleaf fields normally and render the group message on an enclosing boundary.\nSee [Form exception handling](/guide/forms/exceptions#case-4-handle-a-group-or-cross-field-validator)\nfor both rendering options.\n\n### cAsyncValidate\n\nCreates an asynchronous validator based on a resource (query or mutation).\n\n::: warning\nIt is not working yet. We are still working on it. The API is not final and may change.\n:::\n\n```ts\nconst { checkEmailQuery } = query('checkEmailQuery', {\n params: () => ({ email: emailInput() }),\n loader: async ({ params }) => {\n const response = await fetch(`/api/check-email?email=${params.email}`);\n return response.json();\n },\n});\n\ninsertFormAttributes(() => ({\n validators: [\n cAsyncValidate(checkEmailQuery, {\n name: 'emailAvailability',\n exceptionsOnSuccess: ({ validateAsyncCraftResource }) => {\n if (!validateAsyncCraftResource.value()?.available) {\n return craftException({ _tag: 'email-taken' }, undefined);\n }\n return undefined;\n },\n }),\n ],\n}));\n```\n\n## See Also\n\n- [Forms overview](/guide/forms/)\n- [Form exceptions](/guide/forms/exceptions)\n"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"path": "/guide/migration/wave-1-tag-and-provided-in",
|
|
209
|
+
"title": "Migrating to `_tag` and `providedIn`",
|
|
210
|
+
"body": "# Migrating to `_tag` and `providedIn`\n\nTwo renames on the public API:\n\n```ts\n// exceptions\ncraftException({ code: 'UserNotFound' }, payload)\ncraftException({ _tag: 'UserNotFound' }, payload) // after\n\n// services\ncraftService({ name: 'UserApi', scope: 'global' }, …)\ncraftService({ name: 'UserApi', providedIn: 'global' }, …) // after\n```\n\nA codemod ships with this release and does most of the work:\n\n```bash\nnode node_modules/@craft-ts/dev-tools/craft-migrate-errors/rename-field.mjs \\\n tsconfig.json --from=scope --to=providedIn --not-with=_tag\n```\n\nIt is compiler-driven and AST-based: it only rewrites where `tsc` actually\nbreaks, and only in positions the AST confirms are the field. Run it per\ntsconfig, then re-run until it reports `clean`.\n\n## Read this before you trust a green build\n\n**The dangerous part of this migration is invisible.**\n\nIf your code reads the discriminant in a *value* position, the compiler will\npoint at every site and you cannot get it wrong:\n\n```ts\ncraftException({ code: 'X' }) // errors: '_tag' is missing\nexception.code // errors: no such property\n```\n\nIf your code reads it in a **type** position — a conditional or an `Extract` —\nit does not error. It resolves to `never`, and everything downstream quietly\nbecomes empty:\n\n```ts\ntype CodesOf<E> = E extends { code: infer C } ? C : never; // -> never\ntype Only<E> = Extract<E, { readonly code: string }>; // -> never\ntype HasScope<V> = V extends { scope: unknown } ? … : …; // -> the else branch\n```\n\nNothing fails. No test goes red. The capability just stops existing.\n\nThis happened four times inside CraftTS itself while performing this migration,\neach caught late and by accident:\n\n| What broke | How it surfaced |\n|---|---|\n| route exhaustiveness (`CraftExceptionCodes`) | one dev-tools test that compiles fixtures expected to FAIL |\n| component exception codes | a runtime template test, several commits later |\n| settled exception codes | a type assertion in an unrelated spec |\n| route HTTP dependency derivation | a stale-looking assertion, chased on a hunch |\n\nIn every case the whole library suite — 1489 tests — was green.\n\n### What to do about it\n\nRun the finder before you run the codemod, and again afterwards:\n\n```bash\nnode node_modules/@craft-ts/dev-tools/craft-migrate-errors/find-silent-sites.mjs code src\nnode node_modules/@craft-ts/dev-tools/craft-migrate-errors/find-silent-sites.mjs scope src\n```\n\nIt walks the AST for the two shapes that cannot fail loudly — a conditional\nwhose `extends` clause reads the field, and an `Extract`/`Exclude`/`Omit`/`Pick`\nover a literal containing it — and exits non-zero while any remain. Everything\nit lists must be migrated **by hand**: the codemod cannot see them, because the\ncompiler never reports them.\n\nTen such positions existed inside CraftTS. Four were found by accident, over\nseveral days. The other six took this tool about a second.\n\nAnd keep at least one test that asserts something must *not* compile\n(`@ts-expect-error`, or a fixture your build is supposed to reject). It is the\nonly kind of test that notices a guarantee disappearing.\n\n## What did NOT change\n\n`scope` on an exception is untouched:\n\n```ts\ncraftException({ _tag: 'UserNotFound', scope: 'loader' }, payload)\n```\n\nIt says where an exception came from — an origin, not a container or a\nlifetime — so it keeps its name. Only the *service* scope became `providedIn`.\n\nAlso unchanged: the HTTP client's `{ source: 'code' }` matcher and the `code`\nfield of a server response body. Those are the server's vocabulary, not\nCraftTS's, and the codemod leaves them alone by construction.\n\n## Performance\n\nNone of this costs anything. Measured across the rename:\n\n- discrimination: **6.8 ns/op** on both sides;\n- test-suite wall time: **+0.02%**;\n- type-check instantiations on the published build: **−0.00%**.\n\n`@craft-ts/effect` is marginally *cheaper* afterwards, because mapping an\nEffect error onto a craft exception stopped being a transposition and became\nthe identity — Effect and CraftTS now discriminate on the same field.\n"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"path": "/guide/patterns/inject-at-point-of-use",
|
|
214
|
+
"title": "Inject at the point of use",
|
|
215
|
+
"body": "# Inject at the point of use\n\nThis page introduces the first **recommended approach** for structuring a\nCraft application. The useful rule is simple:\n\n> **Get what you need where you need it.**\n\nDeclare a dependency in the smallest factory that actually uses it. If a query\nneeds an API method, the query yields that method. If a route guard needs the\ncurrent user, the guard yields the user service. There is no need to add an\nintermediary method to a component just to forward the call.\n\n## The forwarding shape to avoid\n\nA component should not own dependency resolution, request orchestration, state\nstorage, and loading/error handling in one method:\n\n```typescript\nexport class TasksComponent {\n constructor(private readonly api: TaskApi) {}\n\n readonly tasks = signal<Task[]>([]);\n\n loadTasks() {\n this.api.list().subscribe((tasks) => this.tasks.set(tasks));\n }\n}\n```\n\nThis shape gives the component several different responsibilities: it resolves\nthe API, starts the request, stores the result, and usually reproduces loading\nand error handling as well.\n\nThe actual dependency is also hidden from the outside. Looking at the public\ntype of `TasksComponent` does not tell the compiler, a route, or a test that\n`TaskApi` is required.\n\n## Craft puts the dependency next to the work\n\nWith Craft, the component declares the query directly, and the query yields\nexactly the API operation it needs. In this example, `TaskApi` is a crafted\nservice (or a small boundary adapter):\n\n```typescript\nimport { craftComponent, each, ifBlock, li, p, ul } from '@craft-ts/component';\nimport { query } from '@craft-ts/core';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* query('tasks', {\n params: () => true,\n loader: function* () {\n return yield* TaskApi.list();\n },\n });\n\n return { tasks };\n },\n ({ tasks }) =>\n ifBlock(\n tasks.isLoading,\n () => p('Loading…'),\n () =>\n ul(\n each(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n ),\n);\n```\n\n`TaskApi` is used directly from the `query` loader. The query owns the server\nstate, while the template owns only the rendering of that state. There is no\n`loadTasks()` method, and no extra service whose only job is to forward this\nrequest.\n\n## Why this is useful\n\n### The dependency graph is explicit\n\n`yield* TaskApi.list()` is part of the factory's dependency type. Craft can use\nthe same information for route DI checks, test registers, and dependency\nsnapshots. A missing provider or mock is found at the boundary where it matters.\n\n### Dependencies stay granular\n\nWhen a consumer needs one operation, yield that operation instead of the whole\nservice:\n\n```typescript\nconst list = yield * TaskApi.list();\n```\n\nThe graph records the property that was used. Tests only need to provide\n`list`, and future changes to unrelated API methods do not expand this\nconsumer's contract.\n\n### Async behaviour has one owner\n\n`query` derives the loading, value, and exception state. The component does not\nneed a second signal, subscription, or manual error flag that could drift away\nfrom the request.\n\n## The rule of thumb\n\n- If a query or mutation needs an API operation, yield it in that query or\n mutation.\n- If a service needs another service, yield the dependency in that service's\n factory.\n- If a component needs a dependency directly, yield it in the component's\n factory.\n- Create a dedicated service when it owns reusable behaviour or a meaningful\n boundary — not merely to forward one method call.\n\nDirect does not mean unstructured. The dependency is still named, tracked,\nscoped, mockable, and exposed through a deliberate public API. It simply lives\nclose to the code that uses it.\n\n## See also\n\n- [The mental model](/guide/concepts/mental-model) — declare, yield, derive\n- [`craftService`](/guide/app/craft-service) — define and compose services\n- [Shaping a service's public API](/guide/app/expose-api) — expose only what a\n consumer needs\n- [Testing services](/guide/testing/services) — test the same dependency graph\n- [Architecture rules](/guide/testing/architecture) — constraints across that graph\n"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"path": "/guide/reactivity/after-recomputation",
|
|
219
|
+
"title": "afterRecomputation",
|
|
220
|
+
"body": "# afterRecomputation\n\nRuns a callback **after** a recomputation has settled, rather than in the middle\nof one.\n\n**Use it when** you need to act on a value once the reactive graph is stable —\nscrolling to a freshly rendered row, measuring after a list changed.\n**Not for** ordinary derivation, which belongs in a `computed`.\n\n## Overview\n\nThis function binds queries, mutations, and async methods to sources for automatic execution by:\n\n- Listening to source emissions and computing new values\n- Providing a readonly source suitable for method binding\n- Maintaining reactivity through the effect system\n- Enabling source-based triggering patterns\n\n## Signature\n\n```typescript\nfunction afterRecomputation<State, SourceType>(\n _source: Source<SourceType>,\n callback: (source: SourceType) => State,\n): ReadonlySource<State>;\n```\n\n### Parameters\n\n- **`_source`** - The source to listen to. When this source emits, the callback is invoked.\n- **`callback`** - Function that transforms source values. Receives the emitted value and returns the transformed result.\n\n### Returns\n\nA readonly source that emits transformed values. Can be used as the `method` parameter in queries, mutations, and async methods.\n\n## Primary Use Case\n\nBind queries/mutations/async methods to sources for automatic execution:\n\n```typescript\nmethod: afterRecomputation(mySource, (data) => data);\n```\n\nThis pattern makes queries/mutations execute automatically when the source emits.\n\n## Execution Flow\n\n1. Source emits a value via `source.set(value)`\n2. `afterRecomputation` callback transforms the value\n3. Resulting readonly source emits the transformed value\n4. Bound query/mutation/async method executes with the new value\n\n## Difference from computedSource\n\n- **`afterRecomputation`**: Designed for binding to method parameters\n- **`computedSource`**: General-purpose source transformation\n- Both transform source values, but `afterRecomputation` is optimized for method binding\n\n## Common Patterns\n\n- **Identity transformation**: `afterRecomputation(source, (x) => x)` - pass value through\n- **Field extraction**: `afterRecomputation(source, (data) => data.id)` - extract specific field\n- **Validation**: `afterRecomputation(source, (data) => validate(data))` - transform and validate\n- **Mapping**: `afterRecomputation(source, (data) => mapToDto(data))` - convert to different type\n\n## Examples\n\n### Binding a query to a source for automatic execution\n\n```typescript\nimport { afterRecomputation, query, source$ } from '@craft-ts/core';\n\nconst userIdChange = source$<string>('userIdChange');\nconst { user } = query('user', {\n method: afterRecomputation(userIdChange, (userId) => userId),\n loader: async ({ params }) => {\n const response = await fetch(`/api/users/${params}`);\n return response.json();\n },\n});\n\n// Query executes automatically when source emits\nuserIdChange.emit('user-123');\n// -> query loader executes with params 'user-123'\n\nuserIdChange.emit('user-456');\n// -> query loader executes again with params 'user-456'\n```\n\n### Binding a mutation to a source\n\n```typescript\nimport { afterRecomputation, mutation, source$ } from '@craft-ts/core';\n\nconst submitForm = source$<{ name: string; email: string }>();\nconst { submit } = mutation('submit', {\n method: afterRecomputation(submitForm, (formData) => formData),\n loader: async ({ params }) => {\n const response = await fetch('/api/submit', {\n method: 'POST',\n body: JSON.stringify(params),\n });\n return response.json();\n },\n});\n\n// Mutation executes automatically when source emits\nsubmitForm.emit({ name: 'John', email: 'john@example.com' });\n// -> mutation loader executes with form data\n// Note: No submit.mutate(...) call is needed here\n```\n\n### Binding async method to a source\n\n```typescript\nimport { afterRecomputation, asyncProcess, source$ } from '@craft-ts/core';\n\nconst searchInput = source$<string>('searchInput');\nconst { search } = asyncProcess('search', {\n method: afterRecomputation(searchInput, (term) => term),\n loader: async ({ params }) => {\n // Debounce at source level before setting\n const response = await fetch(`/api/search?q=${params}`);\n return response.json();\n },\n});\n\n// Async method executes automatically\nsearchInput.emit('query');\n// -> search loader executes\n```\n\n### Extracting specific field from complex data\n\n```typescript\ntype FormData = {\n user: { id: string; name: string };\n address: { city: string };\n};\n\nimport { afterRecomputation, mutation, source$ } from '@craft-ts/core';\n\nconst formSubmit = source$<FormData>('formSubmit');\nconst { updateUser } = mutation('updateUser', {\n // Extract only user data\n method: afterRecomputation(formSubmit, (data) => data.user),\n loader: async ({ params }) => {\n const response = await fetch(`/api/users/${params.id}`, {\n method: 'PATCH',\n body: JSON.stringify(params),\n });\n return response.json();\n },\n});\n\n// Only user data is passed to mutation\nformSubmit.emit({\n user: { id: 'user-1', name: 'John' },\n address: { city: 'NYC' },\n});\n// -> mutation receives only { id: 'user-1', name: 'John' }\n```\n\n### Transforming data before execution\n\n```typescript\nimport { afterRecomputation, query, source$ } from '@craft-ts/core';\n\nconst searchParams = source$<{ query: string; filters: string[] }>();\nconst { results } = query('results', {\n method: afterRecomputation(searchParams, (params) => ({\n q: params.query.trim().toLowerCase(),\n f: params.filters.join(','),\n })),\n loader: async ({ params }) => {\n const queryString = new URLSearchParams(params);\n const response = await fetch(`/api/search?${queryString}`);\n return response.json();\n },\n});\n\n// Data is transformed before query execution\nsearchParams.emit({\n query: ' craft ',\n filters: ['tutorial', 'advanced'],\n});\n// -> query receives { q: 'craft', f: 'tutorial,advanced' }\n```\n\n### Validation and type narrowing\n\n```typescript\nimport { afterRecomputation, asyncProcess, source$ } from '@craft-ts/core';\n\nconst inputChange = source$<string>('inputChange');\nconst { validate } = asyncProcess('validate', {\n method: afterRecomputation(inputChange, (input) => {\n // Only proceed if input is valid\n const trimmed = input.trim();\n if (trimmed.length < 3) {\n throw new Error('Input too short');\n }\n return trimmed;\n }),\n loader: async ({ params }) => {\n const response = await fetch('/api/validate', {\n method: 'POST',\n body: JSON.stringify({ input: params }),\n });\n return response.json();\n },\n});\n\n// Invalid input throws error in callback\ninputChange.emit('ab'); // Error: Input too short\n\n// Valid input proceeds\ninputChange.emit('valid input'); // Validation executes\n```\n\n### Multiple sources with different transformations\n\n```typescript\nimport { afterRecomputation, query, source$ } from '@craft-ts/core';\n\nconst quickSearch = source$<string>('quickSearch');\nconst advancedSearch = source$<{ query: string; options: unknown }>();\n\nconst { quickResults } = query('quickResults', {\n method: afterRecomputation(quickSearch, (term) => ({\n query: term,\n mode: 'quick',\n })),\n loader: async ({ params }) => {\n const response = await fetch('/api/search', {\n method: 'POST',\n body: JSON.stringify(params),\n });\n return response.json();\n },\n});\n\nconst { advancedResults } = query('advancedResults', {\n method: afterRecomputation(advancedSearch, ({ query, options }) => ({\n query,\n options,\n mode: 'advanced',\n })),\n loader: async ({ params }) => {\n const response = await fetch('/api/search/advanced', {\n method: 'POST',\n body: JSON.stringify(params),\n });\n return response.json();\n },\n});\n\n// Quick search with simple string\nquickSearch.emit('craft');\n// -> query receives { query: 'craft', mode: 'quick' }\n\nadvancedSearch.emit({\n query: 'craft',\n options: { tags: ['signals'] },\n});\n// -> query receives { query: 'craft', options: { ... }, mode: 'advanced' }\n```\n\n### Identity transformation (pass-through)\n\n```typescript\nimport { afterRecomputation, mutation, source$ } from '@craft-ts/core';\n\nconst dataUpdate = source$<{ id: string; payload: unknown }>();\nconst { update } = mutation('update', {\n // Pass data through unchanged\n method: afterRecomputation(dataUpdate, (data) => data),\n loader: async ({ params }) => {\n const response = await fetch(`/api/data/${params.id}`, {\n method: 'PUT',\n body: JSON.stringify(params.payload),\n });\n return response.json();\n },\n});\n\n// Data passed through unchanged\ndataUpdate.emit({ id: 'item-1', payload: { value: 123 } });\n// -> mutation receives exact same object\n```\n\n## See Also\n\n- [craftEffect](/guide/reactivity/craft-effect)\n- [source$](/guide/reactivity/source)\n"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"path": "/guide/reactivity/craft-computed",
|
|
224
|
+
"title": "craftComputed",
|
|
225
|
+
"body": "# craftComputed\n\nA yieldable reactive value that can read Craft dependencies and other reactive\nCraft values with `yield*`.\n\n**Use it when** a derived value reads a Craft reader, a service, or another\ncomputed — so those dependencies are recorded on the computed itself.\nUse `craftComputed` in application code so every reactive dependency can be\ntraced consistently.\n\n## Import\n\n```typescript\nimport { craftComputed } from '@craft-ts/core';\n```\n\n## Overview\n\n`craftComputed` exposes a yieldable\nreader. Application code uses the generator form so every reactive dependency\nis recorded on **that** computed:\n\n```typescript\nconst counter = yield* state('counter', 1, ({ state }) => ({\n doubled: craftComputed(function* () {\n return (yield* state()) * 2;\n }),\n}));\n\nconst doubled = yield* counter.doubled();\n```\n\nTwo modes exist:\n\n- generator factory: `craftComputed(name, function* () { ...; return value; })`\n — the default for Craft values. The generator is replayed on every\n recomputation.\n- plain computation: `craftComputed(name, () => value)` — for a computation that\n only reads values already held by the surrounding scope.\n\nInside an insertion the name may be omitted: Craft uses the insertion key.\n\n## Signatures\n\n```typescript\nfunction craftComputed<Name extends string, T>(\n name: Name,\n computation: () => T,\n options?: CreateComputedOptions<T>,\n): YieldableReactiveValue<T, Name>;\n\nfunction craftComputed<Name extends string, Yielded, T>(\n name: Name,\n factory: () => Generator<Yielded, T, unknown>,\n options?: CreateComputedOptions<T>,\n): YieldableReactiveValue<T, Name>;\n```\n\nThe first argument is the **name** outside an insertion and must match the\nproperty (or variable) the computed is assigned to. Inside an insertion it may\nbe omitted: Craft uses the insertion key automatically. The name tags the\ninjector context, reactive graph and dev-tools snapshots. The\n[`craft-ts/craft-computed-name-match`](/guide/routing/setup) ESLint rule\nenforces the match and offers a quick fix.\n\n## Generator Computation\n\nUse this form whenever the computed reads a Craft reader, a service, or another\ncomputed.\n\n```typescript\nconst counter = yield* state('counter', 1, ({ state }) => ({\n doubled: craftComputed(function* () {\n return (yield* state()) * 2;\n }),\n}));\n\nconst doubled = yield* counter.doubled();\n```\n\n`doubled` does not own `state()`, so it yields it. That is how the computed's\nown dependency graph records the read.\n\n```typescript\nimport { craftComputed, craftService } from '@craft-ts/core';\n\nconst { Multiplier } = craftService(\n { name: 'Multiplier', providedIn: 'function' },\n () => ({ factor: 3 }),\n);\n\nconst tripled = craftComputed('tripled', function* () {\n const multiplier = yield* Multiplier();\n return (yield* counter()) * multiplier.factor;\n});\n```\n\n## Caveats\n\n- `craftComputed(...)` must be created inside an injection context.\n- Unknown yielded values are rejected with a `craftComputed`-specific error.\n- `onAppStart(...)` is not supported inside `craftComputed(...)`.\n\n## Typing\n\nBoth forms return `YieldableReactiveValue<T>`. The underlying reactive value\nstays internal to Craft.\n\nWhen using a generator, yielded dependencies are tracked and can be extracted with `ExtractDeps<...>`.\n\n## See Also\n\n- [`craftMethod`](/guide/reactivity/craft-method)\n- [`craftEffect`](/guide/reactivity/craft-effect)\n- [`craftService`](/guide/app/craft-service)\n- [`onAppStart`](/guide/app/app-start)\n- [Architecture rules](/guide/testing/architecture) — `assertCraftComputedPure`\n forbids methods and `source$` writes inside a computed\n"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"path": "/guide/reactivity/craft-effect",
|
|
229
|
+
"title": "craftEffect",
|
|
230
|
+
"body": "# craftEffect\n\nAn `effect` that can resolve craft dependencies with `yield*`.\n\n**Use it when** a side effect needs a service.\n**Not as a way to sync state** — if a value is a function of another, derive it\nwith `computed` instead of writing it from an effect.\n\n## Import\n\n```typescript\nimport { craftEffect } from '@craft-ts/core';\n```\n\n```typescript\ncraftEffect('myEffect', function* () {\n const counter = yield* Counter();\n // do some stuff\n});\n```\n\n## Resource triggers\n\nIn generator code, primitive triggers are yieldable and must be consumed with\n`yield*`:\n\n```typescript\nfunction* submit(term: string) {\n yield* searchQuery.call(term);\n yield* saveMutation.mutate({ term });\n yield* validateProcess.method(term);\n}\n```\n\nImperative triggers from ordinary UI callbacks remain valid:\n\n```typescript\nbutton({ click: () => saveMutation.mutate({ term: input() }) }, 'Save');\n```\n\nDo not use those triggers as dependencies of a `craftEffect`. Prefer a\ndeclarative `params` signal, a `source$`, or a mutation/query insertion. The\n`craft-ts/no-imperative-craft-resource-trigger` rule also follows a\n`craftGen`, so wrapping the call does not bypass the restriction:\n\n```typescript\nconst triggerSearch = craftGen(function* (term: string) {\n yield* searchQuery.call(term);\n});\n\ncraftEffect('load', function* () {\n yield* triggerSearch(input()); // forbidden: indirect imperative trigger\n});\n```\n\nUse a reactive query instead when the data depends on a signal:\n\n```typescript\nconst user = yield* query('user', {\n params: userId,\n loader: ({ params }) => fetchUser(params),\n });\n```\n\n## See Also\n\n- [craftComputed](/guide/reactivity/craft-computed)\n- [craftMethod](/guide/reactivity/craft-method)\n- [Local state](/guide/state/local-state) — deriving instead of writing from an effect\n- [Architecture rules](/guide/testing/architecture) — `assertCraftEffectNoNetwork` when an effect calls HTTP or a mutation, `assertCraftEffectNoImperativeSync` when it writes a `state`/`source$` or triggers a query/mutation\n"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"path": "/guide/reactivity/craft-method",
|
|
234
|
+
"title": "craftMethod",
|
|
235
|
+
"body": "# craftMethod\n\nWraps a generator so it can be called like an ordinary method — from a template,\nan event handler, anywhere outside the craft driver — while still resolving its\ndependencies with `yield*`.\n\n**Use it when** a click handler or a component method needs a service.\n**Not inside a craft factory** — there, `yield*` works directly.\n\n## Import\n\n```typescript\nimport { craftMethod } from '@craft-ts/core';\n```\n\n## Overview\n\n`craftMethod` is designed for component methods such as click handlers, submit handlers, and small UI orchestration callbacks.\n\nThe returned method carries the yieldable-method contract. When it is consumed\nfrom a Craft component template, its template view can delegate it with\n`yield*`; the component renderer drives the callback with the Craft generator\nruntime while preserving the method's injector and wrappers.\n\nThe method runs inside the injection context captured when `craftMethod(...)` is created.\n\nThat makes it useful when a component method needs to:\n\n- call Browser Boundaries with `yield*`\n- compose crafted services through `yield* SomeService()`\n- keep the handler colocated with component-local signals\n\n**All dependencies are cached, which helps to detect missing providers at compile time.**\n\n## Signatures\n\n```typescript\nfunction craftMethod<Name extends string, This, Args extends unknown[], Result>(\n name: Name,\n factory: (this: This, ...args: Args) => Generator<unknown, Result, unknown>,\n): (this: This, ...args: Args) => Result;\n\nfunction craftMethod<Name extends string, This, Args extends unknown[], Result>(\n name: Name,\n self: This,\n factory: (this: This, ...args: Args) => Generator<unknown, Result, unknown>,\n): (...args: Args) => Result;\n```\n\nThe first argument is the **name**: it is required and must match the\nproperty (or variable) the method is assigned to. It is the value used to tag\nthe injector context — same role as `provideHostName(...)`. The\n[`craft-ts/craft-method-name-match`](/guide/routing/eslint-rules) ESLint rule\nenforces the match and offers a quick fix.\n\n## The common case — inside a Craft component\n\nIn a Craft component's logic factory there is no `this`: declare the method with\n`craftMethod(name, fn)` and return it in the context.\n\n```typescript\nimport { button, craftComponent, div, p } from '@craft-ts/component';\nimport { Console, craftMethod, state } from '@craft-ts/core';\n\nexport const Counter = craftComponent(\n 'Counter',\n {},\n function* () {\n const counter = yield* state('counter', 0, ({ update }) => ({ update }));\n\n const increment = craftMethod('increment', function* (step = 1) {\n yield* Console.log('increment is called');\n yield* counter.update((value) => value + step);\n });\n\n return { counter, increment };\n },\n ({ counter, increment }) => [\n p(counter),\n button({ click: increment }, 'Increment'),\n ],\n);\n```\n\n\n\n`counter` does not belong to `increment`, so the method yields\n`counter.update`. Pass the method to the template (`click: increment`) rather\nthan wrapping `() => increment()`.\n\n## Composing crafted services\n\n`craftMethod` is not limited to Browser Boundaries — it consumes the same\ncrafted service graph as `craftService`:\n\n```typescript\nconst increment = craftMethod('increment', function* (value: number) {\n return yield* CounterWorker.set(value);\n});\n```\n\n::: details Class-based wrappers — capturing `this`\nWhen a class-based wrapper needs its instance, use one of the two `this`-aware\noverloads.\n\n### Recommended form — capture `this`\n\nUse `craftMethod(name, this, fn)` when the generator needs component state.\n\n```typescript\nimport { Console, craftMethod, craftSignal } from '@craft-ts/core';\n\nexport class Counter {\n readonly counter = craftSignal(0);\n\n readonly increment = craftMethod('increment', this, function* (step = 1) {\n yield* Console.log('increment is called');\n this.counter.update((value) => value + step);\n });\n}\n```\n\nThis overload captures the instance once, so the callback still works after extraction:\n\n```typescript\nconst increment = component.increment;\nincrement();\n```\n\n### Receiver-based form\n\nUse `craftMethod(name, fn)` when you want the method to resolve `this` from its receiver, and are fine with the receiver-dependent behavior.\n\nIn strict TypeScript, annotate `this` explicitly inside the generator:\n\n```typescript\nimport { Console, craftMethod, craftSignal } from '@craft-ts/core';\n\nexport class Counter {\n readonly counter = craftSignal(0);\n\n readonly increment = craftMethod(\n 'increment',\n function* (this: Counter, step = 1) {\n yield* Console.log('increment is called');\n this.counter.update((value) => value + step);\n return this.counter();\n },\n );\n}\n```\n\n### Composing services from a class\n\n```typescript\nexport class Counter {\n readonly increment = craftMethod(\n 'increment',\n this,\n function* (value: number) {\n return yield* CounterWorker.set(value);\n },\n );\n}\n```\n\n:::\n\n## Caveats\n\n- `craftMethod(...)` must be created inside an injection context, typically during component instantiation.\n- The first argument is a required name; it must match the property or variable name. The `craft-ts/craft-method-name-match` ESLint rule enforces this and provides a quick fix.\n- `craftMethod(name, fn)` depends on the receiver used at call time. If you extract the callback, `this` is no longer guaranteed unless you bind it yourself.\n- `craftMethod(name, this, fn)` is the recommended form whenever the generator reads or writes `this`.\n- `onAppStart(...)` is not supported inside `craftMethod`.\n\n## See Also\n\n- [`Browser Boundaries`](/guide/testing/browser-boundaries)\n- [`craftService`](/guide/app/craft-service)\n- [`onAppStart`](/guide/app/app-start)\n"
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"path": "/guide/reactivity/from-event-to-source",
|
|
239
|
+
"title": "fromEventToSource$",
|
|
240
|
+
"body": "# fromEventToSource$\n\nTurns a DOM event into a readonly [`source$`](/guide/reactivity/source), with\nautomatic cleanup.\n\n**Use it when** a primitive should react to something happening on the page:\na scroll, a key, a window resize.\n\n## Overview\n\n`fromEventToSource$` bridges DOM events with craft-ts's reactive system by combining:\n\n- Event conversion to `ReadonlySource$` emissions\n- Automatic event listener cleanup via `DestroyRef`\n- Optional event payload transformation\n- Signal-based reactive access to the last emitted value\n- Manual disposal capability for dynamic use cases\n\n## Import\n\n```typescript\nimport { fromEventToSource$ } from '@craft-ts/core';\n```\n\nThe component examples below also use the hyperscript helpers:\n\n```typescript\nimport { button, craftComponent, div, each, form, input, p } from '@craft-ts/component';\n```\n\n## Signature\n\n```typescript\nfunction fromEventToSource$<T>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue?: never;\n },\n): FromEventToSource$<T>;\n\nfunction fromEventToSource$<T, ComputedValue>(\n target: EventTarget,\n eventName: string,\n options?: {\n event?: boolean | AddEventListenerOptions;\n computedValue: (event: T) => ComputedValue;\n },\n): FromEventToSource$<ComputedValue>;\n```\n\n### Parameters\n\n- **`target`** - The DOM element or event target to listen to (HTMLElement, Window, Document, etc.)\n- **`eventName`** - The event name to listen for ('click', 'input', 'scroll', etc.)\n- **`options`** (optional)\n - **`event`** - Event listener options (capture, passive, once, etc.)\n - **`computedValue`** - Function to transform the event before emission\n\n### Returns\n\n`FromEventToSource$<T>` - A readonly source with:\n\n- **`subscribe(callback: (value: T) => void)`** - Subscribe to event emissions\n- **`value: Signal<T | undefined>`** - Read-only signal containing the last emitted value\n- **`dispose()`** - Method to manually remove the event listener\n\nThe result is also a named yieldable primitive. The yielded source remains\nreadonly and keeps `dispose()`:\n\n```typescript\nconst clickSource = fromEventToSource$(button, 'click');\nconst click = yield* clickSource;\n\nclick.subscribe((event) => console.log(event));\nclick.dispose();\n```\n\n## Types\n\n### FromEventToSource$\n\n```typescript\ntype FromEventToSource$<T> = ReadonlySource$<T> & {\n dispose: () => void;\n} & NamedCraftPrimitiveGen<\n string,\n ReadonlySource$<T> & {\n dispose: () => void;\n }\n >;\n```\n\n### ReadonlySource$\n\n```typescript\ntype ReadonlySource$<T> = {\n subscribe: (callback: (value: T) => void) => Subscription;\n value: Signal<T | undefined>;\n};\n```\n\n## Key Features\n\n### Source services and dependency tracking\n\nExpose the event source through a `craftService` when consumers should depend\non the event handle:\n\n```typescript\nconst { Click } = craftService(\n { name: 'Click', providedIn: 'global' },\n function* () {\n const click = yield* fromEventToSource$(button, 'click');\n return click;\n },\n);\n\nconst counter = yield* state('counter', 0, ({ set }) => ({\n click: on$(Click, () => set(1)),\n}));\n```\n\n`on$(Click, ...)` tracks `Click`. Calling `dispose()` only removes the DOM\nlistener and does not alter dependency metadata.\n\n### Automatic Cleanup\n\nEvent listeners are automatically removed when the injection context is destroyed:\n\n\n\n\n### Signal Integration\n\nAccess the last emitted value reactively via the `value` signal:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n});\n\n// Use in template or computed\nconst trimmedValue = craftComputed('trimmedValue', function* () {\n return (yield* input$.value())?.trim() ?? '';\n});\n```\n\n### Event Transformation\n\nTransform events before emission using `computedValue`:\n\n```typescript\nconst resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n});\n\n// resize$.value() returns { width: number; height: number } | undefined\n```\n\n### Integration with State\n\nUse with `on$()` to trigger state updates on DOM events:\n\n```typescript\nimport { state, on$, fromEventToSource$ } from '@craft-ts/core';\n\nconst button = document.querySelector('button')!;\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\nconst { counter } = state('counter', 0, ({ update }) => ({\n increment: on$(click$, () => update((count) => count + 1)),\n}));\n```\n\n## Examples\n\n### Basic Click Counter\n\n```typescript\nimport { craftComponent, p } from '@craft-ts/component';\nimport { fromEventToSource$, on$, state } from '@craft-ts/core';\n\nexport const Clicker = craftComponent(\n 'Clicker',\n {},\n function* () {\n const click$ = fromEventToSource$<MouseEvent>(document, 'click');\n\n const clicks = yield* state('clicks', 0, ({ update }) => ({\n // bound to the source, so NOT exposed on the ref\n increment: on$(click$, () => update((count) => count + 1)),\n }));\n\n return { clicks };\n },\n ({ clicks }) =>\n p(function* () {\n return `Clicks: ${yield* clicks()}`;\n }),\n);\n```\n\n### Input Value Tracking\n\n```typescript\nexport const Search = craftComponent(\n 'Search',\n {},\n function* () {\n const input$ = fromEventToSource$(document, 'input', {\n computedValue: (event: Event) => (event.target as HTMLInputElement).value,\n });\n\n // reactive access to the current input value\n return { searchTerm: input$.value };\n },\n ({ searchTerm }) => [\n input({ type: 'text', placeholder: 'Search…' }),\n p(function* () {\n return `You typed: ${(yield* searchTerm()) || 'nothing yet'}`;\n }),\n ],\n);\n```\n\n### Window Scroll Tracking\n\n```typescript\nexport const InfiniteScroll = craftComponent(\n 'InfiniteScroll',\n {},\n function* () {\n const scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => ({\n scrollY: window.scrollY,\n scrollHeight: document.documentElement.scrollHeight,\n clientHeight: window.innerHeight,\n }),\n event: { passive: true }, // optimize performance\n });\n\n scroll$.subscribe((data) => {\n const nearBottom =\n data.scrollY + data.clientHeight >= data.scrollHeight - 100;\n\n if (nearBottom) {\n loadMoreData();\n }\n });\n\n return { scrollPosition: scroll$.value };\n },\n ({ scrollPosition }) =>\n div(\n p(function* () {\n return `Scroll position: ${(yield* scrollPosition())?.scrollY}`;\n }),\n ),\n);\n```\n\n### Window Resize Handling\n\n```typescript\nexport const Responsive = craftComponent(\n 'Responsive',\n {},\n function* () {\n const resize$ = fromEventToSource$(window, 'resize', {\n computedValue: () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n });\n\n const dimensions = resize$.value;\n\n return {\n dimensions,\n isMobile: craftComputed('isMobile', function* () {\n const dims = yield* dimensions();\n return dims ? dims.width < 768 : false;\n }),\n };\n },\n ({ dimensions }) =>\n div(\n p(function* () {\n const dims = yield* dimensions();\n return `Viewport: ${dims?.width} x ${dims?.height}`;\n }),\n ),\n);\n```\n\n### Keyboard Shortcuts\n\n\n\n\n### Dynamic Element Listening\n\n```typescript\nexport const Dynamic = craftComponent(\n 'Dynamic',\n {},\n function* (items: Input<readonly Item[]>) {\n let currentListener$: FromEventToSource$<MouseEvent> | undefined;\n\n const attachListener = (element: HTMLElement) => {\n // remove the previous listener, if any\n currentListener$?.dispose();\n\n currentListener$ = fromEventToSource$<MouseEvent>(element, 'click');\n currentListener$.subscribe((event) => {\n console.log('Element clicked:', event);\n });\n };\n\n return { items, attachListener };\n },\n ({ items, attachListener }) =>\n each(\n () => items(),\n { track: (item) => item.id },\n (item) =>\n div(\n button(\n { click: (event) => attachListener(event.target as HTMLElement) },\n 'Attach listener',\n ),\n ),\n ),\n);\n```\n\n### Mouse Position Tracker\n\n```typescript\ninterface Position {\n x: number;\n y: number;\n}\n\nexport const CursorTracker = craftComponent(\n 'CursorTracker',\n {},\n function* () {\n const mouseMove$ = fromEventToSource$(document, 'mousemove', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n event: { passive: true },\n });\n\n return { position: mouseMove$.value };\n },\n ({ position }) =>\n div(\n p(function* () {\n const pos = yield* position();\n return `Mouse position: ${pos?.x}, ${pos?.y}`;\n }),\n ),\n);\n```\n\n### Form Submission\n\n\n\n\n## Comparison with sourceFromEvent\n\n| Feature | `fromEventToSource$` | `sourceFromEvent` |\n| ------------- | ----------------------------------------------------------- | ------------------------------------------------ |\n| Return type | `ReadonlySource$<T>` (with `subscribe`, `value`, `dispose`) | `SignalSource<T>` (with `set`, mutation methods) |\n| Modification | Read-only, no `emit` method | Writable via `set` method |\n| Use case | Event observation and subscription | Event-driven source with manual control |\n| Signal access | ✅ via `value` property | ✅ as direct signal |\n| Subscription | ✅ via `subscribe` method | ❌ (uses `afterRecomputation()`) |\n\n## Best Practices\n\n### Use Passive Event Listeners\n\nFor scroll and mouse events, use `passive: true` to improve performance:\n\n```typescript\nconst scroll$ = fromEventToSource$(window, 'scroll', {\n computedValue: () => window.scrollY,\n event: { passive: true },\n});\n```\n\n### Extract Only Needed Data\n\nTransform events to extract only the data you need:\n\n```typescript\n// ❌ Bad - stores entire event object\nconst click$ = fromEventToSource$<MouseEvent>(button, 'click');\n\n// ✅ Good - extracts only needed properties\nconst click$ = fromEventToSource$(button, 'click', {\n computedValue: (event: MouseEvent) => ({\n x: event.clientX,\n y: event.clientY,\n }),\n});\n```\n\n### Cleanup Dynamic Listeners\n\nFor dynamic elements, manually dispose of listeners:\n\n```typescript\nprivate listener$?: FromEventToSource$<Event>;\n\nattachToElement(element: HTMLElement) {\n this.listener$?.dispose(); // Clean up previous\n this.listener$ = fromEventToSource$(element, 'click');\n}\n\nngOnDestroy() {\n this.listener$?.dispose();\n}\n```\n\n### Combine with State Management\n\nIntegrate with state management using `on$()`:\n\n```typescript\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\nconst { searchResults } = state('searchResults', [], ({ set }) => ({\n search: on$(input$, async (term) => {\n const results = await api.search(term);\n set(results);\n }),\n}));\n```\n\n## Common Patterns\n\n### Debounced Input\n\n```typescript\nimport { debounceTime } from 'rxjs/operators';\n\nconst input$ = fromEventToSource$(inputElement, 'input', {\n computedValue: (e: Event) => (e.target as HTMLInputElement).value,\n});\n\n// Use with rxjs operators if needed\nfrom(input$).pipe(\n debounceTime(300),\n subscribe((value) => console.log(value)),\n);\n```\n\n### Multiple Event Handlers\n\n```typescript\nconst buttonClick$ = fromEventToSource$(button, 'click');\nconst buttonHover$ = fromEventToSource$(button, 'mouseenter');\n\nbuttonClick$.subscribe(() => console.log('Clicked'));\nbuttonHover$.subscribe(() => console.log('Hovered'));\n```\n\n### Conditional Event Processing\n\n```typescript\nconst keydown$ = fromEventToSource$(document, 'keydown', {\n computedValue: (event: KeyboardEvent) => event.key,\n});\n\nkeydown$.subscribe((key) => {\n if (key === 'Escape') {\n this.closeModal();\n } else if (key === 'Enter') {\n this.submit();\n }\n});\n```\n\n## Notes\n\n- Must be called within an injection context\n- Event listeners are automatically removed on component destruction\n- Returns a **readonly** source - no `emit` method is exposed\n- The `value` signal is `undefined` until the first event is emitted\n- Use `dispose()` for manual cleanup when needed\n\n## See Also\n\n- [source$](/guide/reactivity/source) - Event emitter with signal tracking\n- [sourceFromEvent](/guide/reactivity/source-from-event) - Writable source from events\n- [on$](/guide/reactivity/on) - Subscribe to sources in state management\n- [state](/guide/state/local-state) - State primitive with source integration\n"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"path": "/guide/reactivity/on",
|
|
244
|
+
"title": "on$",
|
|
245
|
+
"body": "# on$\n\nBinds a callback to a [`source$`](/guide/reactivity/source), with automatic\ncleanup.\n\n**Use it inside an insertion** to let a primitive react to an event.\n\n::: warning A method bound with `on$` is not exposed\nIt works internally, driven by the source, and does not appear on the primitive's\nref. That is intentional: the source is the trigger, not the caller.\n:::\n\n## Overview\n\n`on$` enables reactive side effects by:\n\n- Listening to source emissions and executing callbacks\n- Automatically unsubscribing when the injection context is destroyed\n- Working with `source$`, `EventEmitter`, and any Observable\n- Accepting a source-returning `craftService` helper directly\n- Returning `SourceBranded` to prevent method exposure in state insertions\n- Providing a clean way to coordinate state updates with source events\n\n## Signature\n\n```typescript\nfunction on$<State, SourceType>(\n source: {\n subscribe: EventEmitter<SourceType>['subscribe'];\n },\n callback: (source: SourceType) => State,\n): SourceBranded;\n\nfunction on$<State, SourceService>(\n source: SourceService,\n callback: (source: SourceServiceOutput) => State,\n): SourceBranded;\n```\n\n### Parameters\n\n- **`_source`** - A source, EventEmitter, or Observable to listen to\n- **`callback`** - Function executed when the source emits. Receives the emitted value and can perform side effects\n\n### Returns\n\n`SourceBranded` - A branded symbol indicating the method is not exposed on the state/store\n\nWhen `source` is a Craft service helper returning a `Source$`, `on$` resolves\nthe helper in the current injection context and tracks that helper as a\ndependency of the containing primitive.\n\n```typescript\nconst { Reset } = craftService(\n { name: 'Reset', providedIn: 'global' },\n function* () {\n const reset$ = yield* source$<void>('reset$');\n return reset$;\n },\n);\n\nconst counter = yield* state('counter', 0, ({ set }) => ({\n reset: on$(Reset, () => set(0)),\n}));\n```\n\n`on$(Reset, ...)` is the dependency edge. `yield* Reset()` can then be used\nto expose the same source for producing events with `reset.emit()`.\n\n## Primary Use Case\n\nCreate internal reactive methods in state insertions that respond to sources without being exposed:\n\n```typescript\nconst { myState } = state('myState', 0, ({ set }) => ({\n // Exposed method\n increment: () => set((v) => v + 1),\n // Internal reactive method (not exposed)\n reset: on$(resetSource$, () => set(0)),\n}));\n\nmyState.increment(); // ✅ Available as a yieldable method (`yield*` / pass the reference)\nmyState.reset(); // ❌ Not available (TypeScript error)\n```\n\n## Automatic Cleanup\n\n`on$` automatically unsubscribes from the source when the injection context is\ndestroyed, preventing memory leaks.\n\n## Common Patterns\n\n- **State reset**: `on$(resetSource, () => set(initialValue))` - reset state on source emission\n- **State synchronization**: `on$(source, (value) => set(value))` - sync state with source\n- **Multi-state coordination**: Multiple states can use `on$` with the same source\n- **Conditional updates**: `on$(source, (value) => { if(condition) set(value) })` - conditional state changes\n\n## Examples\n\n### Basic state reset on source emission\n\n```typescript\nimport { state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\nconst resetSource = source$<void>('resetSource');\n\nconst { counter } = state('counter', 0, ({ set, update }) => ({\n // Exposed methods\n increment: () => update((v) => v + 1),\n decrement: () => update((v) => v - 1),\n // Internal: resets when resetSource emits\n reset: on$(resetSource, () => set(0)),\n}));\n\nconsole.log(yield* counter()); // 0\nyield* counter.increment();\nconsole.log(yield* counter()); // 1\n\nresetSource.emit(); // Triggers reset\nconsole.log(yield* counter()); // 0\n\n// counter.reset() ❌ TypeScript error - not exposed\n```\n\n### Syncing state with a source\n\n```typescript\nimport { state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\ninterface User {\n id: string;\n name: string;\n}\n\nconst userUpdateSource = source$<User>('userUpdateSource');\n\nconst { currentUser } = state(\n 'currentUser',\n null as User | null,\n ({ set }) => ({\n // Exposed method\n clear: () => set(null),\n // Internal: updates when source emits\n syncFromSource: on$(userUpdateSource, (user) => set(user)),\n }),\n);\n\nconsole.log(currentUser()); // null\n\nuserUpdateSource.emit({ id: '1', name: 'Alice' });\nconsole.log(currentUser()); // { id: '1', name: 'Alice' }\n\nuserUpdateSource.emit({ id: '2', name: 'Bob' });\nconsole.log(currentUser()); // { id: '2', name: 'Bob' }\n```\n\n### Coordinating multiple states with a single source\n\n```typescript\nimport { state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\nconst resetAllSource = source$<void>('resetAllSource');\n\nconst { search } = state('search', '', ({ set, update }) => ({\n set,\n clear: () => set(''),\n // Reset when resetAllSource emits\n resetOnSignal: on$(resetAllSource, () => set('')),\n}));\n\nconst { page } = state('page', 1, ({ set, update }) => ({\n next: () => update((v) => v + 1),\n previous: () => update((v) => Math.max(1, v - 1)),\n // Reset when resetAllSource emits\n resetOnSignal: on$(resetAllSource, () => set(1)),\n}));\n\nconst { filters } = state('filters', [] as string[], ({ set }) => ({\n add: (filter: string) => set((current) => [...current, filter]),\n // Reset when resetAllSource emits\n resetOnSignal: on$(resetAllSource, () => set([])),\n}));\n\n// Set some values\nsearch.set('craft');\npage.next();\npage.next();\nfilters.add('tutorial');\nfilters.add('advanced');\n\nconsole.log(search()); // 'craft'\nconsole.log(page()); // 3\nconsole.log(filters()); // ['tutorial', 'advanced']\n\n// Reset all states at once\nresetAllSource.emit();\n\nconsole.log(search()); // ''\nconsole.log(page()); // 1\nconsole.log(filters()); // []\n```\n\n### Using on$ in a craft service\n\n```typescript\nimport { craftService, state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\nconst { Filters } = craftService({ name: 'Filters', providedIn: 'global' }, () => {\n const reset = source$<void>('reset');\n const { search } = state('search', '', ({ set }) => ({\n set,\n // Internal: reset on source emission\n handleReset: on$(reset, () => set('')),\n }));\n const category = state('all', ({ set }) => ({\n set,\n // Internal: reset on source emission\n handleReset: on$(reset, () => set('all')),\n }));\n\n return {\n search,\n category,\n resetFilters: () => reset.emit(),\n };\n});\n\nconst filters = Filters();\n\nfilters.search.set('craft');\nfilters.category.set('frameworks');\n\nconsole.log(filters.search()); // 'craft'\nconsole.log(filters.category()); // 'frameworks'\n\n// Reset all filters\nfilters.resetFilters();\n\nconsole.log(filters.search()); // ''\nconsole.log(filters.category()); // 'all'\n```\n\n\n\n### Conditional state updates\n\n```typescript\nimport { state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\ninterface DataUpdate {\n value: number;\n force?: boolean;\n}\n\nconst dataSource = source$<DataUpdate>('dataSource');\n\nconst { data } = state('data', 0, ({ state, set }) => ({\n // Exposed methods\n setValue: (value: number) => set(value),\n // Internal: conditionally update based on source data\n handleUpdate: on$(dataSource, (update) => {\n // Only update if value is higher or force flag is set\n if (update.force || update.value > state()) {\n set(update.value);\n }\n }),\n}));\n\nconsole.log(data()); // 0\n\ndataSource.emit({ value: 10 });\nconsole.log(data()); // 10\n\ndataSource.emit({ value: 5 });\nconsole.log(data()); // 10 (not updated, 5 < 10)\n\ndataSource.emit({ value: 3, force: true });\nconsole.log(data()); // 3 (updated due to force flag)\n```\n\n### Working with complex transformations\n\n```typescript\nimport { state, source$ } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\ninterface ApiResponse {\n data: {\n items: Array<{ id: string; value: number }>;\n };\n metadata: {\n total: number;\n };\n}\n\nconst apiResponseSource = source$<ApiResponse>('apiResponseSource');\n\nconst { items } = state(\n 'items',\n [] as Array<{ id: string; value: number }>,\n ({ set }) => ({\n add: (item: { id: string; value: number }) =>\n set((current) => [...current, item]),\n // Internal: extract and set items from API response\n handleApiResponse: on$(apiResponseSource, (response) => {\n set(response.data.items);\n }),\n }),\n);\n\nconst { totalCount } = state('totalCount', 0, ({ set }) => ({\n // Internal: extract and set total from API response\n handleApiResponse: on$(apiResponseSource, (response) => {\n set(response.metadata.total);\n }),\n}));\n\napiResponseSource.emit({\n data: {\n items: [\n { id: '1', value: 100 },\n { id: '2', value: 200 },\n ],\n },\n metadata: {\n total: 2,\n },\n});\n\nconsole.log(items()); // [{ id: '1', value: 100 }, { id: '2', value: 200 }]\nconsole.log(totalCount()); // 2\n```\n\n### Using with EventEmitter\n\n```typescript\nimport { EventEmitter } from '@craft-ts/core';\nimport { state } from '@craft-ts/core';\nimport { on$ } from '@craft-ts/core';\n\nconst clickEmitter = new EventEmitter<{ x: number; y: number }>();\n\nconst { lastClick } = state(\n 'lastClick',\n null as { x: number; y: number } | null,\n ({ set }) => ({\n clear: () => set(null),\n // Internal: update on emitter events\n handleClick: on$(clickEmitter, (position) => set(position)),\n }),\n);\n\nclickEmitter.emit({ x: 100, y: 200 });\nconsole.log(lastClick()); // { x: 100, y: 200 }\n\nclickEmitter.emit({ x: 150, y: 250 });\nconsole.log(lastClick()); // { x: 150, y: 250 }\n```\n\n## Best Practices\n\n✅ **Use for internal state coordination** - Perfect for state updates that shouldn't be exposed as methods\n✅ **Coordinate multiple states** - Use the same source with multiple `on$` calls across different states\n✅ **Keep callbacks simple** - Focus on state updates, avoid heavy computation\n✅ **Leverage automatic cleanup** - No need to manually unsubscribe\n✅ **Prefer for side effects** - Use `on$` for actions, `afterRecomputation` for transformations\n\n❌ **Don't expose complex logic** - Keep the callback focused on state changes\n❌ **Don't use for query/mutation params** - Use `afterRecomputation` instead\n❌ **Don't chain multiple on$ calls** - Keep it simple and flat\n\n## Related\n\n- [`source$`](/guide/reactivity/source) - Create reactive sources\n- [`afterRecomputation`](/guide/reactivity/after-recomputation) - Transform sources for method parameters\n- [`state`](/guide/state/local-state) - Create reactive state with insertions\n- [`craftService`](/guide/app/craft-service) - Organize coordinated states inside reusable services\n\n## See Also\n\n- [source$](/guide/reactivity/source) — what `on$` listens to\n- [fromEventToSource$](/guide/reactivity/from-event-to-source)\n- [Local state](/guide/state/local-state) — event-driven methods\n"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"path": "/guide/reactivity/source",
|
|
249
|
+
"title": "source$",
|
|
250
|
+
"body": "# source$\n\nAn event source: something you `emit()` to, that others react to — with\nautomatic cleanup and signal-based value tracking.\n\n**Use it when** several independent pieces of state must react to one event: a\n\"reset everything\" action, a refresh trigger, a cross-cutting notification.\n**Not for** a direct call from A to B — that is just a method.\n\n## Overview\n\n`source$` provides a lightweight event streaming solution that combines:\n\n- Event emission and subscription capabilities\n- Automatic subscription cleanup via `DestroyRef`\n- Signal-based value tracking for reactive access\n- Optional last value preservation for late subscribers\n- Read-only variants for encapsulation\n\n## Import\n\n```typescript\nimport { craftComputed, source$ } from '@craft-ts/core';\n```\n\n## Signature\n\n```typescript\nfunction source$<T>(name: string): Source$<T>;\n```\n\n### Parameters\n\n- **`name: string`** - Name matching the variable/property this source is assigned to. Used for host tagging and dev-tools snapshot reporting, consistent with `craftComputed`/`craftEffect`. The [`craft-ts/craft-source-name-match`](/guide/routing/eslint-rules) ESLint rule enforces the match and offers a quick fix.\n\n### Returns\n\n`Source$<T>` is directly usable as a source and can also be consumed with\n`yield*`:\n\n```typescript\n// Direct source API\nconst source = source$<void>('reset$');\nsource.emit();\n\n// Yieldable primitive API\nconst reset$ = yield* source$<void>('reset$');\n```\n\nThe yielded value is the source instance. Its source API is unchanged:\n\n- **`emit(value: T)`** - Emits a value to all subscribers and updates the internal signal\n- **`subscribe(callback: (value: T) => void)`** - Subscribes to emissions with a callback\n- **`value: Signal<T | undefined>`** - A read-only signal containing the last emitted value (or `undefined` if no value has been emitted)\n- **`asReadonly()`** - Returns a read-only version of the source (only `subscribe` and `value`)\n- **`preserveLastValue()`** - Returns a source variant that immediately emits the last value to new subscribers\n\n## Types\n\n### Source$\n\n```typescript\ntype Source$<T> = SourceInstance<T> &\n NamedCraftPrimitiveGen<string, SourceInstance<T>>;\n\ntype SourceInstance<T> = {\n emit: (value: T) => void;\n subscribe: (callback: (value: T) => void) => Subscription;\n value: Signal<T | undefined>;\n asReadonly: () => ReadonlySource$<T>;\n preserveLastValue: () => {\n emit: (value: T) => void;\n subscribe: (callback: (value: T) => void) => void;\n value: Signal<T | undefined>;\n asReadonly: () => {\n subscribe: (callback: (value: T) => void) => void;\n value: Signal<T | undefined>;\n };\n };\n};\n```\n\nThe generator side yields `{ [name]: SourceInstance<T> }`. The source side\nkeeps the existing `emit`, `subscribe`, `value`, `asReadonly` and\n`preserveLastValue` API.\n\n### ReadonlySource$\n\n```typescript\ntype ReadonlySource$<T> = {\n subscribe: (callback: (value: T) => void) => Subscription;\n value: Signal<T | undefined>;\n};\n```\n\n## Key Features\n\n### Source services and dependency tracking\n\nUse a `craftService` as the dependency handle when a source is shared by\nmultiple consumers:\n\n\n\n\n`on$(Reset, ...)` records `Reset` as a dependency of the primitive. Calling\n`reset.emit()` only publishes an event; it does not create or modify the\ndependency graph.\n\n### Automatic Cleanup\n\nSubscriptions are automatically cleaned up when the injection context is destroyed, preventing memory leaks:\n\n```typescript\nconst userAction$ = source$<string>('userAction$');\n\n// Subscription is automatically unsubscribed on component destruction\nuserAction$.subscribe((action) => console.log(action));\n```\n\n### Signal Integration\n\nThe `value` property provides reactive access to the last emitted value:\n\n```typescript\nconst message$ = source$<string>('message$');\n\nmessage$.emit('Hello');\nconsole.log(message$.value()); // 'Hello'\n\n// Use in templates or craftComputed\nconst uppercased = craftComputed('uppercased', function* () {\n return (yield* message$.value())?.toUpperCase();\n});\n```\n\n### Last Value Preservation\n\nUse `preserveLastValue()` to ensure late subscribers receive the most recent value:\n\n```typescript\nconst counter$ = source$<number>('counter$');\ncounter$.emit(42);\n\n// Standard source: late subscriber receives nothing\ncounter$.subscribe((v) => console.log('Standard:', v)); // Only future values\n\n// With preserveLastValue: late subscriber gets the last value immediately\nconst preserved$ = counter$.preserveLastValue();\npreserved$.subscribe((v) => console.log('Preserved:', v)); // Logs: Preserved: 42\n```\n\n## Common Patterns\n\n### Event Broadcasting\n\n```typescript\nconst buttonClick$ = source$<MouseEvent>('buttonClick$');\n\n// Multiple subscribers\nbuttonClick$.subscribe((event) => console.log('Logger:', event));\nbuttonClick$.subscribe((event) => trackEvent('button_click'));\n\n// Emit events\nbutton.addEventListener('click', (e) => buttonClick$.emit(e));\n```\n\n### Read-Only Access\n\n```typescript\nclass DataService {\n private dataUpdated$ = source$<Data>('dataUpdated$');\n\n // Expose read-only version\n readonly dataUpdated = this.dataUpdated$.asReadonly();\n\n updateData(data: Data) {\n this.dataUpdated$.emit(data);\n }\n}\n```\n\n### Coordination with State\n\n```typescript\nconst resetTrigger$ = source$<void>('resetTrigger$');\n\nconst { counter } = state('counter', 0, ({ set, update }) => ({\n increment: () => update((v) => v + 1),\n decrement: () => update((v) => v - 1),\n // Reset when source emits\n reset: on$(resetTrigger$, () => set(0)),\n}));\n```\n\n## Examples\n\n### Basic Usage with on$\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { on$, source$, state } from '@craft-ts/core';\n\nexport const Counter = craftComponent(\n 'Counter',\n {},\n function* () {\n // a source for reset events\n const reset$ = source$<void>('reset$');\n\n const counter = yield* state('counter', 0, ({ set, update }) => ({\n increment: () => update((v) => v + 1),\n decrement: () => update((v) => v - 1),\n // internal: listens to reset$ and sets counter to 0.\n // NOT exposed on the ref, because it is bound with on$\n reset: on$(reset$, () => set(0)),\n }));\n\n return { counter, reset$ };\n },\n ({ counter, reset$ }) => [\n p(function* () {\n return `Count: ${yield* counter()}`;\n }),\n button({ click: counter.increment }, '+1'),\n button({ click: counter.decrement }, '-1'),\n button({ click: () => reset$.emit() }, 'Reset'),\n ],\n);\n```\n\n### Multi-Source Coordination\n\n```typescript\nimport { source$, state, on$ } from '@craft-ts/core';\n\n// Multiple sources for different events\nconst userLogin$ = source$<User>('userLogin$');\nconst userLogout$ = source$<void>('userLogout$');\n\nconst { authState } = state<User | null>('authState', null, ({ set }) => ({\n // Respond to multiple sources\n onLogin: on$(userLogin$, (user) => set(user)),\n onLogout: on$(userLogout$, () => set(null)),\n}));\n\n// Trigger events\nuserLogin$.emit({ id: 1, name: 'Alice' });\nconsole.log(authState()); // { id: 1, name: 'Alice' }\n\nuserLogout$.emit();\nconsole.log(authState()); // null\n```\n\n### Late Subscriber Pattern\n\n```typescript\nimport { source$ } from '@craft-ts/core';\n\nconst notifications$ = source$<string>('notifications$').preserveLastValue();\n\n// Emit before any subscribers\nnotifications$.emit('Server started');\nnotifications$.emit('Database connected');\n\n// Late subscriber receives the last value immediately\nsetTimeout(() => {\n notifications$.subscribe((msg) => {\n console.log('Late subscriber:', msg); // Logs: Late subscriber: Database connected\n });\n}, 1000);\n```\n\n## Related\n\n- [on$](/guide/reactivity/on) - Subscribe to sources with automatic cleanup in state insertions\n\n## See Also\n\n- [on$](/guide/reactivity/on) — reacting to a source\n- [fromEventToSource$](/guide/reactivity/from-event-to-source)\n- [sourceFromEvent](/guide/reactivity/source-from-event)\n"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"path": "/guide/reactivity/source-from-event",
|
|
254
|
+
"title": "sourceFromEvent",
|
|
255
|
+
"body": "# sourceFromEvent\n\nCreates a [`source$`](/guide/reactivity/source) from DOM events or event\nemitters.\n\n**Use it when** the event's origin is an emitter or an element you already hold,\nrather than a target resolved lazily — for that, see\n[`fromEventToSource$`](/guide/reactivity/from-event-to-source).\n\n## Import\n\n```typescript\nimport { sourceFromEvent } from '@craft-ts/core';\n```\n\n## Basic Usage\n\n```typescript\nimport { state, sourceFromEvent } from '@craft-ts/core';\n\nconst button = document.querySelector('button')!;\n\n// Create source from button clicks\nconst clickSource = sourceFromEvent(\n button,\n 'click',\n () => (count: number) => count + 1,\n);\n\nconst { clickCount } = state('clickCount', 0, {\n sources: [clickSource],\n});\n```\n\n## API\n\n```typescript\nfunction sourceFromEvent<T, E extends Event>(\n target: EventTarget,\n eventName: string,\n mapper: (event: E) => (state: T) => T,\n): Observable<(state: T) => T>;\n```\n\n## Examples\n\n### Inside a Craft component\n\n`sourceFromEvent` takes an `EventTarget` you already hold. Inside a component\nthat usually means a document- or window-level target, since the component's own\nelements are better handled with a plain event prop:\n\n```typescript\nimport { craftComponent, p } from '@craft-ts/component';\nimport { sourceFromEvent, state } from '@craft-ts/core';\n\nexport const KeyCounter = craftComponent(\n 'KeyCounter',\n {},\n function* () {\n const keySource = sourceFromEvent(\n document,\n 'keydown',\n () => (count: number) => count + 1,\n );\n\n const keys = yield* state('keys', 0, { sources: [keySource] });\n\n return { keys };\n },\n ({ keys }) =>\n p(function* () {\n return `Keys pressed: ${yield* keys()}`;\n }),\n);\n```\n\n::: tip For the component's own elements, use an event prop\n`button({ click: counter.increment }, 'Click me')` is simpler and needs\nno target. Reach for `sourceFromEvent` when the event comes from outside the\ncomponent's own markup, or when several states must react to the same event.\n:::\n\n### Input Changes\n\n```typescript\nconst input = document.querySelector('input')!;\n\nconst inputSource = sourceFromEvent(\n input,\n 'input',\n (event: Event) => () => (event.target as HTMLInputElement).value,\n);\n\nconst { inputValue } = state('inputValue', '', {\n sources: [inputSource],\n});\n```\n\n### Mouse Position\n\n```typescript\ninterface Position {\n x: number;\n y: number;\n}\n\nconst mouseMoveSource = sourceFromEvent(\n document,\n 'mousemove',\n (event: MouseEvent) => () => ({\n x: event.clientX,\n y: event.clientY,\n }),\n);\n\nconst { mousePosition } = state<Position>(\n 'mousePosition',\n { x: 0, y: 0 },\n {\n sources: [mouseMoveSource],\n },\n);\n```\n\n### Keyboard Input\n\n```typescript\nconst keySource = sourceFromEvent(\n document,\n 'keydown',\n (event: KeyboardEvent) => (keys: string[]) => [...keys, event.key],\n);\n\nconst { pressedKeys } = state<string[]>('pressedKeys', [], {\n sources: [keySource],\n});\n```\n\n### Scroll Position\n\n```typescript\nconst scrollSource = sourceFromEvent(\n window,\n 'scroll',\n () => () => window.scrollY,\n);\n\nconst { scrollPosition } = state('scrollPosition', 0, {\n sources: [scrollSource],\n});\n```\n\n### Form Submit\n\n```typescript\nconst form = document.querySelector('form')!;\n\ninterface FormData {\n name: string;\n email: string;\n}\n\nconst submitSource = sourceFromEvent(form, 'submit', (event: Event) => {\n event.preventDefault();\n const form = event.target as HTMLFormElement;\n const formData = new FormData(form);\n return () => ({\n name: formData.get('name') as string,\n email: formData.get('email') as string,\n });\n});\n\nconst { formState } = state<FormData>(\n 'formState',\n { name: '', email: '' },\n {\n sources: [submitSource],\n },\n);\n```\n\n### Window Resize\n\n```typescript\ninterface WindowSize {\n width: number;\n height: number;\n}\n\nconst resizeSource = sourceFromEvent(window, 'resize', () => () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n}));\n\nconst { windowSize } = state<WindowSize>(\n 'windowSize',\n { width: window.innerWidth, height: window.innerHeight },\n {\n sources: [resizeSource],\n },\n);\n```\n\n## With Operators\n\n```typescript\nimport { debounceTime, map } from 'rxjs/operators';\n\nconst input = document.querySelector('input')!;\n\n// Debounced input with RxJS operators\nconst debouncedInputSource = sourceFromEvent(\n input,\n 'input',\n (event: Event) => () => (event.target as HTMLInputElement).value,\n).pipe(debounceTime(300));\n\nconst { searchQuery } = state('searchQuery', '', {\n sources: [debouncedInputSource],\n});\n```\n\n## Best Practices\n\n✅ **Cleanup automatically handled** - Sources unsubscribe when state is destroyed\n✅ **Use with throttle/debounce** - For high-frequency events\n✅ **Extract to reusable functions** - Create helper functions for common patterns\n✅ **Type your events** - Use specific event types (MouseEvent, KeyboardEvent)\n\n## See Also\n\n- [`source$`](/guide/reactivity/source) - Create reactive sources\n"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"path": "/guide/routing/automation",
|
|
259
|
+
"title": "CLI automation",
|
|
260
|
+
"body": "# CLI automation\n\nWriting a typed route by hand means four pieces that must agree: the route, its\n`componentDeps`, the `withRetry` wrapper and the DI check. The CLI writes all\nfour, and the output stays ordinary editable TypeScript.\n\n**Use it for** day-to-day route authoring and for migrating an existing app.\n**Then edit the result** — nothing here is generated code you must not touch.\n\n`@craft-ts/dev-tools` provides codemods to migrate an existing application to\nCraft primitives, services, type-safe routes, and selectorless Craft Components.\n\n## Install the migration tool\n\n```shell\nnpm install @craft-ts/core\nnpm install --save-dev @craft-ts/dev-tools@beta\n```\n\nThe migration binaries are available starting with `0.5.1-beta.0` and are\ncurrently published on the `beta` tag. The `latest` version and older beta\nversions do not include `craft-migrate`. If the package was installed before\nthat release, update it and verify the resolved version:\n\n```shell\nnpm install --save-dev @craft-ts/dev-tools@beta\nnpm ls @craft-ts/dev-tools\n```\n\nCommit or stash the current application changes before running a migration in\nwrite mode.\n\n## Run the complete migration\n\nPreview all migrations first:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --dry-run\n```\n\nThen apply them:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\n`craft-migrate` runs the migrations in the required order:\n\n1. `craft-migrate-primitives`\n2. `craft-migrate-services`\n3. `craft-migrate-routes`\n4. `craft-migrate-components`\n5. `craft-migrate-architecture`\n\nThe `--write` command also runs ESLint fixes on the touched files. Use\n`--no-eslint` only when your project runs this step separately.\n\n## Run a targeted migration\n\nUse an individual codemod when the earlier stages have already been migrated:\n\n```shell\nnpx craft-migrate-routes \\\n --project tsconfig.app.json \\\n --root src \\\n --dry-run\n\nnpx craft-migrate-routes \\\n --project tsconfig.app.json \\\n --root src \\\n --write\nnpx craft-migrate-components \\\n --project tsconfig.app.json \\\n --root src \\\n --write\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThe route migration converts supported route collections to\n`craftRoutes(...)`, adds type-safe route metadata, and reports transformations\nthat require a manual decision.\n\nFor a nested route collection, provide its mount context when it cannot be\ninferred safely:\n\n```shell\nnpx craft-migrate-routes src/app/admin/admin.routes.ts \\\n --project tsconfig.app.json \\\n --parent-mount admin \\\n --parent-names CurrentUser,Permissions \\\n --write\n```\n\n## Review diagnostics\n\nWrite the complete report to a JSON file:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --dry-run \\\n --json migration-report.json\n```\n\nResolve every manual diagnostic before considering the migration complete.\nIn particular, verify generated `componentDeps`, inherited route providers,\nlazy child collections, and the file-level DI checks.\n\n## Add a CI check\n\nAfter applying and reviewing the migration, prevent supported legacy patterns\nand unresolved manual diagnostics from returning:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --check \\\n --fail-on-manual\n```\n\nFinish with the application's normal lint, type-check, test, and build commands.\nSee the [complete migration guide](/resources/migration) for the post-codemod checklist.\n\n## Make the DI contract enforceable\n\nThe CLI writes the route, `componentDeps`, `withRetry` and the DI proof. Those\nproofs are unused type aliases: omit one and the project still compiles. That\nis the one fragile step in an otherwise compile-time guarantee.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) close it.\n`assertRouteDiProofs` walks the static graph and fails unless every routed\ncomponent — including lazy `loadChildren` collections — every pending or error\nscreen, and every `craftAppConfig` error surface is hooked to an armed mapper.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\nAdd `assertRouteDiProofs` to the app's architecture suite and run it in CI.\nThat is the application-facing check for the routing contract.\n\n## Compiler fixture suite (optional)\n\n`craft route verify` is a separate, heavier check: it type-checks the project,\nthen writes temporary valid and invalid fixtures covering route DI, `toProvide`\nproviders, lazy child checks, route params and inputs, Craft templates,\ntemplates, pending/error components, lazy loading, guard/resolve/component\nexceptions, local recovery and exhaustive handlers. Invalid fixtures are\nexpected to fail, and their diagnostics are matched with the expected `path`,\n`pending component` or `exception component` context.\n\nUse it when you need to regression-test the type machinery itself — not as the\napp's proof that *your* routes still carry `CanRun`. Architecture tests cover\nthat.\n\n```json\n{\n \"scripts\": {\n \"craft:verify-routes\": \"craft route verify --project tsconfig.app.json\"\n }\n}\n```\n\n```shell\nnpm run craft:verify-routes\n```\n\nFixtures are removed in a `finally` block. Use `--json` for a machine-readable\nreport, `--root` when the application source root is not detected\nautomatically, and `--keep-fixtures` only while diagnosing a failed\nverification. `--project` and `--tsconfig` are aliases for selecting the app\ntsconfig.\n\nThis validates compile-time and ESLint bookkeeping guarantees. Runtime\nchunk-loading scenarios remain covered by the browser tests.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — what the CLI generates for you\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` is the app-facing routing check\n- [Scaling routes](/guide/routing/scaling) — `craft route split`\n"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"path": "/guide/routing/eslint-rules",
|
|
264
|
+
"title": "ESLint rules",
|
|
265
|
+
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/require-cascade-route-di-check': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifBlock(...)`, `matchBlock.exhaustive(...)`, `each(...)`, or `defer(...)`\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest`; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of`; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/no-throw`: forbids `throw` in Craft code and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator that only returns `yield* callback()` with the callback reference itself\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/require-cascade-route-di-check`: rejects any `craftRoutes(...)` collection without a same-file `ValidateCascadeRoutesFile + CanRun` proof; its autofix adds the conservative `<never, Router>` context, which should be adjusted when the mount inherits providers\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifBlock(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchBlock.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton({ disabled: function* () {\n return !(yield* machine.canGoBack());\n} }, 'Back');\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users = yield* query(\n 'users',\n config,\n ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }),\n);\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchBlock.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ---------------------------------------------------------- |\n| `require-cascade-route-di-check` | the same-file DI proof for a `craftRoutes(...)` collection |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | removes redundant template generators |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `prefer-craft-http-client`, `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"path": "/guide/routing/exception-handling",
|
|
269
|
+
"title": "Route exception handling",
|
|
270
|
+
"body": "# Route exception handling\n\nWhen a guard, a matcher or a resolver raises a declared exception, this page is\nwhere you say what happens next: redirect, render a dedicated component, stay\nput, or carry on. One map per route resolves the **union** of every code those\nthree steps can produce — and the compiler checks that the map is exactly\ncomplete, no more and no less.\n\n**Use it when** a route's guards, matchers or resolvers can fail in ways the user\nshould see.\n**Not when** the failure is local to one primitive — read it off\n`exceptions()` instead, see\n[Exceptions as values](/guide/concepts/exceptions).\n\n::: warning Breaking change\nEvery handler must use `craftExceptionHandler(function* (...) {})`. Internal\nredirects use `yield* redirectTo({ to, params, queryParams, viewTransition })`;\nopaque URLs or prebuilt `UrlTree` values use `redirectUrl(...)`.\n`renderComponent`, route-level `errorComponent` and `withErrorComponent` accept\nonly `{ component | loadComponent, componentDeps }` descriptors. Bare handler\nfunctions, `redirect(...)` and bare error components are rejected.\n:::\n\n## The common case\n\n```ts\nUSER_DISABLED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () =>\n import('./user-disabled-error-page').then(\n (m) => m.UserDisabledErrorPage,\n ),\n componentDeps:\n {} as import('./user-disabled-error-page').GenDeps_UserDisabledErrorPage,\n });\n}),\n\nNOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({\n to: 'auth/login',\n queryParams: { reason: 'session-expired' },\n });\n}),\n```\n\n`canActivate` / `canMatch` / `resolve` stay your **writing** API — each may raise\na typed [`craftException`](/guide/routing/guards#exceptions). Instead of an\ninline resolver map per guard, a single **`handleExceptions`** map on the route\nresolves the union of every code reachable from those three steps. The\nnon-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui) applies the chosen\noutcome **after the URL has committed**, so a slow guard never freezes\nnavigation.\n\nThe route result also exposes a route-scoped signal helper per code, such as\n`injectDemoUserIdUserDisabledException()`. It returns the exact exception and\npayload for the locally rendered branch, and is cleared on the next navigation.\n\n## A full route, end to end\n\n```ts\nconst { profileQuery } = query('profileQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/profile',\n success: response<Profile>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(403))) return;\n if (!(yield* code('USER_DISABLED'))) return;\n return craftException({ _tag: 'USER_DISABLED' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'user/:userId',\n {\n loadComponent: ({ withRetry }) => withRetry(import('./user-detail')),\n componentDeps: {} as import('./user-detail').GenDeps_UserDetail,\n canMatch: function* () {\n const ff = yield* FeatureFlags();\n return ff.userPageEnabled ? true : craftException({ _tag: 'FEATURE_OFF' });\n },\n canActivate: function* () {\n const user = yield* Auth();\n return user.value() ?? craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(profileQuery);\n }),\n },\n {\n FEATURE_OFF: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'home' });\n }),\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo, phase }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n USER_DISABLED: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n),\n```\n\n`canActivate` / `canMatch` are bare generator functions — there is no guard wrapper and no inline\n`resolvers` argument. Every reachable code flows to the third `craftRoute(...)` argument.\n\n## Handler context\n\nEvery handler receives a `CraftExceptionHandlerContext` typed for its exception code:\n\n| Field | Type / purpose |\n| ----------------- | -------------------------------------------------------------------------------------------- |\n| `exception` | The complete typed `craftException`, including `code`, `scope`, and `payload`. |\n| `payload` | The typed payload passed as the second argument of `craftException(...)`. |\n| `phase` | `'enter'` during initial activation, `'active'` during a live guard re-check. |\n| `router` | The active `Router` instance. |\n| `createUrlTree` | Bound `Router.createUrlTree`, useful for building a redirect with query params or fragments. |\n| `navigate` | Bound `Router.navigate`. Imperative; prefer returning `yield* redirectTo(...)`. |\n| `navigateByUrl` | Bound `Router.navigateByUrl`. Imperative; prefer a redirect outcome. |\n| `redirectTo` | Typed internal redirect checked against `META_PATHS`; yields `CraftRouter`. |\n| `redirectUrl` | Explicit escape hatch for an opaque string URL or `UrlTree`. |\n| `renderComponent` | Builds an outcome that renders a dedicated component. |\n| `globalError` | Delegates rendering to the application-wide error component. |\n| `stay` | Restores the previous URL and keeps the triggering page. |\n| `noop` | Continues to the target despite the exception. Resolve data remains `undefined`. |\n\nA handler is always a synchronous generator wrapped with `craftExceptionHandler`. It may resolve\nservices but cannot suspend with `craftUntilSettled` / `craftUntilDefined`.\n\n## Outcomes\n\nEach handler receives a context and returns an outcome constructor:\n\n| Outcome | Effect |\n| ----------------------------- | -------------------------------------------------------------------------------------------------------- |\n| `yield* redirectTo(input)` | Navigate to a registered internal route with typed params/query params/view transition. |\n| `redirectUrl(target)` | Navigate to an opaque string URL or `UrlTree`. |\n| `renderComponent(descriptor)` | Render a DI-checked `{ component \\| loadComponent, componentDeps }` descriptor. |\n| `globalError()` | Render the application-wide error component (see [global error component](./global-error-component.md)). |\n| `stay()` | Cancel the navigation; restore the previous URL (stay on the triggering page). |\n| `noop()` | Render the target anyway, with `resolve` data left `undefined`. |\n\nThe context also carries the typed `exception`, its `payload`, the native `redirect`\nhelpers (`createUrlTree` / `navigate` / `navigateByUrl`), and the navigation `phase` (see below). A\nhandler may be a **generator** that `yield*`s craft services before its outcome.\n\n## Examples\n\n### Typed payload and `UrlTree`\n\nUse `redirectTo(...)` for registered application routes and `redirectUrl(...)` for a prebuilt\n`UrlTree`:\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nHere `payload` is inferred from `craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 })`.\n\n### Initial entry versus live guard\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ phase, redirectTo }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n}\n```\n\n### Local, global, stay, and noop outcomes\n\n```ts\n{\n ACCOUNT_LOCKED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n component: AccountLockedPage,\n componentDeps: {} as import('./account-locked-page').GenDeps_AccountLockedPage,\n });\n }),\n MAINTENANCE: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () => import('./maintenance-page').then((m) => m.MaintenancePage),\n componentDeps: {} as import('./maintenance-page').GenDeps_MaintenancePage,\n });\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }),\n UNSAVED_CHANGES: craftExceptionHandler(function* ({ stay }) { return stay(); }),\n OPTIONAL_PROFILE_UNAVAILABLE: craftExceptionHandler(function* ({ noop }) { return noop(); }),\n}\n```\n\nThe descriptor is checked independently with the O(1)\n`RouteExceptionComponentCheckedDI`; it is not added to `ValidateCascadeRoutesFile`.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if\nthat proof is missing or not armed with `CanRun`.\n\n### Handler using a craft service\n\n```ts\n{\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const config = yield* RedirectConfig();\n return redirectUrl(config.unauthorizedUrl);\n }),\n}\n```\n\nDependencies yielded by handlers participate in route DI checking, like dependencies yielded by\nguards and resolvers.\n\n## Exhaustiveness\n\nThe union is only resolvable once the whole collection is inferred, so exhaustiveness is asserted\n**after** `craftRoutes` (mirroring the cascade DI check) rather than inline on each route:\n\n```ts\nexport const { demoRoutes } = craftRoutes('demo', [\n /* … */\n]);\n\n// Compile error if any route's handleExceptions misses — or over-covers — a reachable code.\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\n`craftRoutes(...)` collection has no `assertExhaustiveRouteExceptions`.\n\nA missing code (e.g. `resolve` can throw `USER_DISABLED` but no handler) **and** an extra code (a\nhandler for a code nothing can produce) are both type errors, naming the offending route + codes.\n\n## Pitfalls\n\n**`HttpError` appears or disappears depending on the `craftUntilSettled` form.**\nThis is the most common surprise:\n\n- `craftUntilSettled(CraftHttpClient.get(...))` **excludes** `HttpError` from the\n routable union and rethrows it. The outlet sends that navigation error to the\n global error component.\n- `craftUntilSettled(queryRef)` routes every exception the query exposes. When\n its loader returns a `CraftHttpClient` request, that **includes** `HttpError`,\n so the route must declare an explicit handler such as\n `HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); })`.\n\nDeclared business exceptions remain routable in both forms.\n\n**A handler cannot suspend.** It may `yield*` services, but not\n`craftUntilSettled` / `craftUntilDefined`.\n\n**Over-covering is an error too.** A handler for a code nothing can produce fails\nthe exhaustiveness assert, same as a missing one.\n\n::: details The `phase` field\n`phase` distinguishes the initial activation (`'enter'`) from a reactive\nre-evaluation (`'active'`) of a live `canActivate` guard (see [live\nguards](/guide/routing/guards#reactive-guards)). Use it to soften a reaction\nmid-session — a different redirect reason on session expiry, say — or ignore the\nreactive phase entirely with `noop()`.\n:::\n\n## See Also\n\n- [Exceptions as values](/guide/concepts/exceptions) — the concept\n- [Route guards](/guide/routing/guards) — where exceptions are raised\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n"
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"path": "/guide/routing/global-error-component",
|
|
274
|
+
"title": "Global error component",
|
|
275
|
+
"body": "# Global error component\n\nOne component, declared once, for every failure a route decided not to handle\nlocally.\n\n**Use it when** several exception codes deserve the same screen, or as the\nbackstop for unexpected errors.\n**Not when** a specific failure needs its own UI — `renderComponent(...)` in the\nroute's handler is more precise. See\n[Route exception handling](/guide/routing/exception-handling).\n\nWhen a route exception handler delegates to `globalError()`, the outlet renders one\napplication-wide error component and feeds it the exception. That component can read **all** of its\npossible exceptions — typed and exhaustive — because the codes routed to it are mirrored in a global\nregistry maintained automatically by ESLint.\n\n## Register the component\n\nPass `withErrorComponent(...)` directly to `provideCraftRouter(...)` (mixed with\nyour router features):\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withComponentInputBinding(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\nIt also works standalone via `provideCraftLoading(withErrorComponent({ component,\ncomponentDeps }))`.\n\n## Consume the exception\n\n```ts\nexport const MyGlobalErrorScreen = craftComponent(\n 'MyGlobalErrorScreen',\n {},\n function* () {\n // Signal<USER_DISABLED | HttpError | …>\n const error = yield* CraftGlobalError();\n\n return {\n message: computed(() => {\n switch (error()?.code) {\n case 'USER_DISABLED':\n return 'This account is disabled.';\n default:\n return 'Something went wrong.';\n }\n }),\n };\n },\n ({ message }) => div(h1(() => message())),\n);\n```\n\n`CraftGlobalError()` is typed as the **union of every exception** any route delegates to the\nglobal component, so `switch (error().code)` is exhaustively typed. The outlet writes the active\nexception into `CRAFT_GLOBAL_ERROR` just before rendering the component.\n\n## The registry (auto-maintained)\n\nThe union comes from `CraftGlobalExceptionRegistry`, keyed by route path and code:\n\n```ts\ndeclare module '@craft-ts/core' {\n interface CraftGlobalExceptionRegistry {\n 'user/:userId': {\n USER_DISABLED: CraftRouteExceptionType<\n typeof demoRoutes,\n 'user/:userId',\n 'USER_DISABLED'\n >;\n HttpError: CraftRouteExceptionType<\n typeof demoRoutes,\n 'user/:userId',\n 'HttpError'\n >;\n };\n }\n}\n```\n\n**Do not edit this block by hand.** The `craft-ts/global-exception-registry-match` ESLint rule\ndetects every `handleExceptions` handler that calls `globalError()` and keeps the registry in sync:\n\n```bash\nnpx nx lint demo --fix\n```\n\nA missing entry is reported as an error; `--fix` inserts the `[path][code]` entry. `CraftRouteExceptionType`\nresolves the typed exception object for a code on a route from the collection's route definitions\n(no type checker required — the rule builds the reference from the collection variable and the\npath/code literals).\n\nThe screen itself still needs an armed `RouteExceptionComponentCheckedDI` in\n`app.config.ts`. [Architecture tests](/guide/testing/architecture#assertroutediproofs)\nfail if that proof is missing.\n\n## Default behaviour\n\nIf no `withErrorComponent` is configured, `globalError()` and unhandled thrown errors leave the\noutlet in its `error` state without a component. Provide a global error component to render a\nfallback UI.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling) — where `globalError()` is returned\n- [Route load errors](/guide/routing/route-load-errors)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"path": "/guide/routing/guards",
|
|
279
|
+
"title": "Route guards",
|
|
280
|
+
"body": "# Route guards\n\nA guard is a bare `function*` on `canActivate` / `canMatch`. It yields what it\nneeds, and returns either a value or a `craftException` describing why the route\nmust not render.\n\n**Use one when** access to a route depends on state: authentication, a role, a\nfeature flag, an onboarding step.\n**Not when** the answer is a redirect with no condition — that is a static\nroute.\n\nGuards here are **reusable and parameterised**, they **compose** inside a single\n`canActivate` / `canMatch`, and their failure cases are resolved\n**exhaustively** — an unhandled case is a **type error**.\n\n> **A guard is just a generator function.** `canActivate` / `canMatch` take a bare\n> `function* () { … }` directly — there is no `craftCanActivate` / `craftCanMatch` wrapper and no\n> inline `resolvers` argument. Every reachable `craftException` is resolved by a single, exhaustive\n> **[`handleExceptions`](/guide/concepts/exceptions)** map on the route, applied **after the URL commits**\n> by the non-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui).\n\n## The problem\n\nA `craftRoutes` `canActivate` accepts a single function (or generator function). To apply several\nauthorization rules — role, account state, feature flag… — you have to inline everything into one\ngenerator and hand-roll each rejection by returning a `createUrlTree(...)`:\n\n```ts\ncanActivate: function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user }));\n if (!user()) {\n return createUrlTree(['/auth/login']); // not authenticated\n }\n if (user()!.role !== 'admin') {\n return createUrlTree(['/unauthorized']); // wrong role\n }\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria }));\n if (pizzeria()) {\n return createUrlTree(['/dashboard']); // already onboarded\n }\n return true;\n}\n```\n\nThe rules are not reusable, the redirect logic is tangled with the checks, and nothing forces you to\nhandle every rejection — forget a branch and it silently falls through.\n\n## The solution: `craftGen` + a composing generator guard\n\nSplit the two concerns:\n\n- **`craftGen`** authors a reusable, parameterised guard. It either returns a success value or a\n typed [`craftException`](#exceptions).\n- The route's **`canActivate` generator** composes guards with `yield*`; the route's exhaustive\n [`handleExceptions`](/guide/concepts/exceptions) map must cover **exactly** the reachable exception codes.\n\nFor a focused overview of `craftGen` itself and why it is useful, see\n[`craftGen`](/guide/concepts/generators).\n\n```ts\nimport {\n craftException,\n craftGen,\n craftResolve,\n CraftHttpClient,\n query,\n craftRoute,\n craftUntilSettled,\n} from '@craft-ts/core';\n\n// Reusable guards — each returns a success value | craftException(...)\nconst roleGuard = craftGen(\n (...roles: Role[]) =>\n function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({\n user,\n }));\n if (!user()) return craftException({ _tag: 'NOT_AUTHENTICATED' });\n return roles.includes(user()!.role)\n ? true\n : craftException({ _tag: 'FORBIDDEN_ROLE' });\n },\n);\n\nconst noPizzeriaGuard = craftGen(\n () =>\n function* () {\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({\n pizzeria,\n }));\n return pizzeria() ? craftException({ _tag: 'HAS_PIZZERIA' }) : true;\n },\n);\n\nconst { pizzeriaDraftQuery } = query('pizzeriaDraftQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/pizzerias/draft',\n success: response<PizzeriaDraft>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(404))) return;\n return craftException({ _tag: 'PIZZERIA_DRAFT_UNAVAILABLE' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'new',\n {\n title: 'Create Pizzeria',\n canActivate: function* () {\n yield* roleGuard(ROLES.PIZZERIA_ADMIN); // short-circuits on exception\n yield* noPizzeriaGuard();\n return true;\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(pizzeriaDraftQuery);\n }),\n loadComponent: ({ withRetry }) =>\n withRetry(\n import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page'),\n ).then((m) => m.AdminPizzeriaFormPage),\n componentDeps:\n {} as import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page').GenDeps_AdminPizzeriaFormPage,\n },\n {\n // Resolved centrally — exhaustive over canActivate ∪ canMatch ∪ resolve.\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n HAS_PIZZERIA: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'pizzerias/admin' });\n }),\n PIZZERIA_DRAFT_UNAVAILABLE: craftExceptionHandler(function* ({\n globalError,\n }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n);\n\n// After the collection is defined, assert every route handles exactly its codes:\n// assertExhaustiveRouteExceptions(adminRoutes);\n```\n\n## Reactive guards\n\nWhile a route is active, its `canActivate` invariant stays **under observation** (live guards, on by\ndefault). If a signal the guard reads changes — e.g. the user logs out and `Auth` becomes `null` —\nthe guard re-evaluates synchronously and applies [`handleExceptions`](/guide/concepts/exceptions) with\n`phase: 'active'`, so the target is never left rendered in an incoherent state. The reactive phase\nnever re-runs `resolve` (no new pending). Opt out per route with `reactiveGuards: false`.\n\n## How composition works\n\n`craftGen(factory)` returns a factory you invoke and delegate to with `yield*`:\n\n- The guard's **dependency yields** (`CraftAuth`, `CraftRouter`, …) flow up to the\n route exactly as in a plain generator guard, so [cascade DI tracking](/guide/routing/setup)\n still sees them.\n- As soon as a composed guard produces a `craftException`, the enclosing generator\n **short-circuits**: `yield* roleGuard(...)` interrupts the whole `function*`, and the exception is\n propagated to the route's guard boundary — no `if`/`return` plumbing in the composing guard.\n- The set of exceptions each guard can produce is tracked **at the type level**, so the route's\n `handleExceptions` map knows precisely which codes it must handle.\n\nOrder matters: guards run top-to-bottom and the first exception wins (fail-fast).\n\n## The handler context\n\nEach route exception handler receives the typed exception and payload, the navigation phase, the\ntyped `Router` helpers, and the five outcome constructors. See\n[Centralised Exception Handling](/guide/concepts/exceptions#handler-context) for the exhaustive list\nand examples.\n\nUse `redirectTo(...)` for typed internal routes:\n\n```ts\n{\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nA handler returns a `CraftExceptionOutcome` via `redirectTo`, `redirectUrl`, `renderComponent`,\n`globalError`, `stay`, or `noop`. The `payload` is taken from `craftException({ _tag }, payload)`'s second argument\nand typed per code.\n\n## Handlers can yield services\n\nA handler may be a **generator** that `yield*`s craft services before building the redirect — for\nexample to read the login URL from a config service. Those yields are tracked exactly like the\nguards' own dependencies, so a service used only at redirect-time still flows into the route's\n[cascade DI](/guide/routing/setup) (yield an unprovided service and it surfaces as a\nmissing-provider error on the route):\n\n```ts\ncraftRoute(\n 'admin',\n {\n canActivate: function* () {\n yield* roleGuard(ROLES.ADMIN);\n return true;\n },\n },\n {\n // Generator handler — `RedirectConfig` becomes a tracked route dependency.\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const { unauthorizedUrl } = yield* RedirectConfig();\n return redirectUrl(unauthorizedUrl);\n }),\n },\n);\n```\n\nEvery handler uses the generator wrapper, including handlers that do not yield a service.\n\n## Exhaustiveness\n\nThe handler map is typed over the reachable codes, so **every** reachable code must be handled — a\nmissing one is a type error:\n\n```ts\ncraftRoute(\n 'admin',\n { canActivate: guard },\n {\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n // Type error: Property 'HAS_PIZZERIA' is missing.\n },\n);\n```\n\nAdd a guard that can raise a new code, and every route using it stops compiling until its handler is\nadded. A typo'd code is caught the same way because the correctly-spelled key is then missing.\n\n## Guarded data still flows through\n\nA `canActivate` guard's **success value** (anything other than `true`/`UrlTree`/…) becomes the\nroute's [guarded data](/guide/routing/route-providers) — `craftException` returns are never\ntreated as data:\n\n```ts\nconst authGuard = craftGen(\n () =>\n function* () {\n const user = yield* Auth();\n const userValue = user.value();\n return userValue\n ? userValue\n : craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n);\n\ncraftRoute(\n 'query/:userId',\n {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n return yield* authGuard(); // success value = the user\n },\n },\n {\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/login-form');\n }),\n },\n).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n return (yield* GuardedData())(); // Signal<User> → User\n }),\n]);\n```\n\n## `canMatch`\n\n`canMatch` is the sibling of `canActivate` — same composition and exhaustive resolution through\n`handleExceptions`. Unlike `canActivate`, a `canMatch` guard produces no guarded data.\n\n```ts\nconst featureFlagGuard = craftGen(\n (flag: string) =>\n function* () {\n const { flags } = yield* CraftConfig();\n return flags[flag] ? true : craftException({ _tag: 'FLAG_DISABLED' });\n },\n);\n\ncraftRoute(\n 'beta',\n {\n componentDeps: {} as import('./beta').GenDeps_Beta,\n loadComponent: ({ withRetry }) => withRetry(import('./beta')),\n canMatch: function* () {\n yield* featureFlagGuard('beta');\n return true;\n },\n },\n {\n FLAG_DISABLED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/home');\n }),\n },\n);\n```\n\n## Async guards {#async-guards}\n\nThe guards above are **synchronous** — every `craftGen` resolves in one pass. To decide based on data\nthat has to be _fetched first_, suspend the composing guard with `craftUntilSettled` (or `craftUntilDefined`).\nThe guard stays a normal generator: `yield* a(); const x = yield* craftUntilSettled(...); yield* b()`\ncomposes across the await, and the awaited operation's `craftException`s flow into the same\nexhaustive `handleExceptions` map — the compiler still forces you to handle every reachable code.\n\n### `craftUntilSettled` — await a resource or an HTTP call\n\n`craftUntilSettled` takes either a craft **resource** (`query` / `mutation` / `asyncProcess`) or a\n`CraftHttpClient.*` **call** and suspends until it settles, then returns its success value.\n\n```ts\ncraftRoute(\n 'users/:userId',\n {\n componentDeps: {} as import('./user').GenDeps_User,\n loadComponent: ({ withRetry }) => withRetry(import('./user')),\n canActivate: function* (route) {\n const userId = route.params['userId'];\n\n // (a) Await an HTTP call directly — no named resource needed. Its declared\n // `exceptions` flow into the route's handleExceptions below.\n const user = yield* craftUntilSettled(\n CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${userId}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeature',\n });\n },\n ],\n })),\n );\n\n return user.active ? true : craftException({ _tag: 'INACTIVE_USER' });\n },\n },\n {\n // Both the guard's own exception AND the HTTP call's exception are required.\n INACTIVE_USER: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/inactive');\n }),\n PASSWORD_REQUIRED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/password');\n }),\n },\n);\n```\n\nThe **resource** form is identical — pass the ref (an inline `query(name, ...)` works, though it is\nreactive; prefer the HTTP form for one-shots):\n\n```ts\nconst user =\n yield *\n craftUntilSettled(\n query('user', {\n params: () => userId,\n loader: ({ params }) => fetchUser(params),\n }).user,\n );\n```\n\n**Settle semantics & exception routing:**\n\n- A resource settles when its `status` reaches `'resolved'` or `'error'`. A loader `craftException`\n **short-circuits** to `handleExceptions`; a thrown loader error is **rethrown**; otherwise the\n resolved value is returned.\n- An HTTP call's declared business `exceptions` short-circuit to `handleExceptions`. The generic\n transport-level `HttpError` (`scope: 'HttpClient'`) is **rethrown** — a network failure is not a\n resolvable business case. (An opt-in `HttpError` handler may come later.)\n- The awaited HTTP endpoint is tracked as a route dependency automatically, exactly like one used in\n a component or loader.\n\n### `craftUntilDefined` — await a readiness signal\n\n`craftUntilDefined(signal)` suspends until `signal()` is no longer `undefined`, then returns its\nnon-nullable value. There is no exception channel — use it to wait on a plain readiness signal.\n\n```ts\nconst session = yield * craftUntilDefined(sessionService.current);\n```\n\n### Notes\n\n- A guard that never reaches an `craftUntilSettled` / `craftUntilDefined` await still resolves **synchronously**\n (no forced microtask) — existing synchronous guards are unchanged.\n- This works for both `canActivate` and `canMatch`; the outlet drives the guard to settlement after\n the URL commits.\n\n## Exceptions {#exceptions}\n\nGuards fail with `craftException({ _tag }, payload?)` — the same typed-exception primitive used by\n`query` / `mutation`:\n\n```ts\ncraftException({ _tag: 'FORBIDDEN_ROLE' });\ncraftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 }); // payload reaches the handler\n```\n\nThe `code` drives both the exhaustiveness check and the handler lookup; the optional payload is\ntyped and forwarded to the handler.\n\n## When to reach for it\n\n`craftGen` + a `canActivate` / `canMatch` generator fit **sequential, fail-fast gates resolved at a\nsingle boundary**: authorization, account-state checks, feature flags, action preconditions.\n\nIt is **not** the right tool when you want to **collect and surface multiple failures**\nreactively — that is what `query` / `mutation` `hasException` and the form-submit exception model are\nfor. Guards stop at the first failure and hand off to a handler.\n\n## See Also\n\n- [Route Providers](/guide/routing/route-providers) — consume guarded data in route providers\n- [Setup](/guide/routing/setup) — the app-wide cascade DI check\n- [craftService](/guide/app/craft-service) — services yielded inside guards\n"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"path": "/guide/routing/pending-ui",
|
|
284
|
+
"title": "Non-blocking navigation",
|
|
285
|
+
"body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Token | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CRAFT_PENDING_COMPONENT` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CRAFT_LOADING_TEXT` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CRAFT_STAY_MS` / `CRAFT_BLANK_MS` / `CRAFT_PENDING_MIN_MS` | `300` / `300` / `0` |\n| `withErrorComponent` | `CRAFT_ERROR_COMPONENT` | `null` |\n| `withRouteLoadError` | `CRAFT_ROUTE_LOAD_ERROR_COMPONENT` / `CRAFT_ROUTE_LOAD_RETRY` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CRAFT_VIEW_TRANSITIONS_ENABLED` / `CRAFT_VIEW_TRANSITION_SKIP_BLANK` | `false` / `false` |\n| `withA11yNavigationFocus` | `CRAFT_A11Y_NAVIGATION_FOCUS` | `false` |\n\nThe default pending component renders `CRAFT_LOADING_TEXT`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren` (kept out of the parent's cascade DI budget).\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\n[craftRouterLink]=\"{\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n}\"\n```\n\nThe skeleton (and/or the target) reads it through the **route-generated typed helper** and wears the\nmatching `view-transition-name`:\n\n```ts\nexport default class PhotoSkeleton {\n protected readonly photoId = injectPhotosPhotoIdParams();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …), but the aggregated cascade (`ValidateCascadeRoutesFile`) only sees the **target**\ncomponent — it never descends into `pendingComponent`. So the skeleton is verified **directly**, with\nthe per-component, O(1) [`RouteCheckedDI`](/guide/routing/setup#escape-hatch-the-o-1-per-route-check) escape\nhatch (not a second aggregated pass — that would add to the instantiation-count budget the cascade is\nalready spending):\n\n```ts\ntype _CheckTargetDI = ValidateCascadeRoutesFile<\n AppNames,\n AppValues,\n typeof photosRoutes\n>;\ntype _CanRunTarget = CanRun<_CheckTargetDI>;\n\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the cascade check uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's `GenDeps_*`, deriving the auto-provided\nservice names from the route's path params + payload, and borrowing the parent context from the\ncollection's own `ValidateCascadeRoutesFile` — so you never hand-write or stale it.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"path": "/guide/routing/route-load-errors",
|
|
289
|
+
"title": "Route load errors",
|
|
290
|
+
"body": "# Route load errors\n\nThis is the failure mode nothing else covers: the route is valid, the guards\npassed, and the **JavaScript chunk itself** never arrives — a stale hash after a\ndeploy, a flaky network, an offline user.\n\n**Use it when** your app is lazy-loaded and deployed more than once. Which is to\nsay: use it.\n\n`withRouteLoadError(...)` handles failures that happen before Craft can mount the target route:\nlazy `loadComponent` / `loadChildren` chunks that fail to load, rejected dynamic imports, stale\ndeployments, CDN errors, or offline transitions.\n\nThis is different from [`handleExceptions`](/guide/concepts/exceptions): route exceptions are business\nexceptions raised by guards, resolvers, or route code. Route load errors happen while Craft is\ntrying to fetch the JavaScript needed to activate the route.\n\n## Register the route-load error screen\n\nPass `withRouteLoadError(...)` to `provideCraftRouter(...)`, next to the router features and\nother craft loading features:\n\n```ts\nimport {\n provideCraftRouter,\n withRouteLoadError,\n withErrorComponent,\n} from '@craft-ts/core';\n\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps:\n {} as import('./my-global-error-screen').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 1,\n delayMs: 250,\n },\n }),\n);\n```\n\nThe component must be eager. Do not configure the route-load error screen with `loadComponent`: the\nfailure case is precisely that lazy JavaScript may be unavailable.\n\n## Runtime behaviour\n\nWhen a lazy route load fails, Craft:\n\n1. runs the configured retry strategy;\n2. converts the final failure to a `craftException` with code `CRAFT_ROUTE_LOAD_ERROR`;\n3. renders the configured route-load error component;\n4. keeps the browser URL on the original target URL.\n\nThe last point matters. Internally, Craft activates a technical recovery route so there is\nsomething safe to render, but `browserUrl` keeps the visible URL as the intended route:\n\n```text\n/mutation/123\n→ lazy chunk fails\n→ retry fails\n→ route-load error screen is shown\n→ browser URL stays /mutation/123\n→ F5 reloads /mutation/123 and retries the real route\n```\n\n::: info No dedicated loading UI during JavaScript fetches yet\nWhile Craft is fetching a lazy `loadComponent` / `loadChildren` chunk, including time spent in the\nconfigured retry strategy, Craft does not currently display the route's `pendingComponent` or another\ndedicated loading component. The pending component starts only after the JavaScript has loaded and the\nroute has been activated, while the Craft `canMatch` / `canActivate` / `resolve` chain is running.\n\nExtending the pending timeline to cover slow chunk downloads and retries is planned as a future\nevolution. Until then, the previous route may remain visible while the JavaScript request is pending;\nthe route-load error component appears only after all configured retries fail.\n:::\n\n::: warning Browser-cached module failures\nBrowsers can remember a failed dynamic `import()` for the exact same module specifier. Wrap each\nCraft lazy route import with the loader's `withRetry` helper:\n\n```ts\nloadComponent: ({ withRetry }) => withRetry(import('./detail')),\nloadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n```\n\nThe initial import remains statically analyzable, so Craft and Vite still rewrite it to the hashed\nproduction chunk. On a configured retry, Craft extracts the emitted chunk URL from the browser\nerror and adds `__craft_route_retry` only to the failed request. A successful retry module is kept\nfor the lifetime of the application and reused by later route activations.\n\nThis recovery depends on the browser including the failed module URL in the dynamic-import error.\nWhen it does not, `reload()` remains the reliable recovery path. Do not write\n`import(withRetryPrefix('./detail'))`: a runtime import specifier prevents the production chunk from\nbeing statically discovered.\n:::\n\n## Build the error component\n\nThe component can inject both the active technical exception and the recovery API:\n\n```ts\nimport { button, craftComponent, div, h2, p } from '@craft-ts/component';\nimport {\n CraftRouteLoadError,\n CraftRouteLoadRecovery,\n provideHostName,\n} from '@craft-ts/core';\n\nexport const MyRouteLoadErrorScreen = craftComponent(\n 'MyRouteLoadErrorScreen',\n {\n providers: [provideHostName('component:MyRouteLoadErrorScreen')],\n styles: `\n :scope { padding: 2rem; border: 1px solid #f97316; border-radius: 8px }\n .actions { display: flex; gap: .75rem; margin-top: 1rem }\n `,\n },\n function* () {\n return {\n error: yield* CraftRouteLoadError(),\n recovery: yield* CraftRouteLoadRecovery(),\n };\n },\n ({ error, recovery }) => {\n const current = error();\n\n return div([\n h2('Route could not be loaded'),\n p(\n current\n ? `Failed to load ${current.payload.phase} for route \"${current.payload.routePath}\" after ${current.payload.attempt} attempts.`\n : 'The requested route chunk could not be loaded.',\n ),\n div({ class: 'actions' }, [\n button({ click: () => void recovery.retry() }, 'Retry route load'),\n button({ click: () => recovery.reload() }, 'Reload app'),\n ]),\n ]);\n },\n);\n```\n\n`CraftRouteLoadError()` yields a signal of the reserved `craftException`. Its payload includes:\n\n- `phase`: `'component'` or `'children'`;\n- `routePath`: the route definition path that failed;\n- `targetUrl`: the URL the user tried to reach;\n- `cause`: the final error thrown by the loader/retry strategy;\n- `attempt`: the number of load attempts made.\n\n`injectCraftRouteLoadRecovery().retry()` navigates back to `targetUrl`; `reload()` refreshes the\nbrowser.\n\n## Configure retry globally\n\nThe default retry is one retry after 250 ms. You can make it explicit in `withRouteLoadError(...)`:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 2,\n delayMs: 500,\n },\n});\n```\n\n`attempts` is the number of retry attempts after the initial failure. So `attempts: 2` means at most\nthree loader calls total: the initial call plus two retries.\n\nUse callbacks when retry behaviour depends on the error:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n attempts: 3,\n shouldRetry: (error, context) => {\n // Only retry dynamic import / chunk loading failures.\n if (!(error instanceof TypeError)) return false;\n\n // Stop earlier for a route where retrying is known to be useless.\n return context.routePath !== 'admin';\n },\n delayMs: (_error, context) => {\n // Simple backoff: retry attempt 2 waits 250 ms, attempt 3 waits 500 ms, …\n return 250 * (context.attempt - 1);\n },\n },\n});\n```\n\nThe retry context passed to callbacks contains `phase`, `routePath`, `targetUrl`, `attempt`, and\n`error`. The `attempt` value is the load attempt about to run. After the first failed load, the\nfirst retry callback receives `attempt: 2` and `error` set to the initial failure.\n\nFor custom logic, pass a retry strategy:\n\n```ts\nwithRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./my-route-load-error-screen').GenDeps_MyRouteLoadErrorScreen,\n retry: {\n async execute(loader, context) {\n console.warn('route load failed, retrying', context);\n return loader();\n },\n },\n});\n```\n\nThe strategy can also be an injectable class implementing `CraftRouteLoadRetry`.\n\n## Override per route\n\nBoth the retry strategy and the rendered component are regular DI providers. Override them on a\nspecific route when the failure should have local behaviour:\n\n```ts\nimport {\n provideRouteLoadErrorComponent,\n provideRouteLoadRetry,\n} from '@craft-ts/core';\n\ncraftRoute('admin', {\n providers: [\n provideRouteLoadRetry({\n attempts: 3,\n delayMs: 1_000,\n }),\n provideRouteLoadErrorComponent({\n component: AdminRouteLoadErrorScreen,\n componentDeps:\n {} as import('./admin-route-load-error-screen').GenDeps_AdminRouteLoadErrorScreen,\n }),\n ],\n loadChildren: ({ withRetry }) =>\n withRetry(import('./admin.routes')).then((m) => m.adminRoutes),\n});\n```\n\nThe local component receives the same `injectCraftRouteLoadError()` and\n`injectCraftRouteLoadRecovery()` values, resolved through the failing route's injector.\n\n## DI checks\n\nRoute-load error components participate in the same generated DI checks as other error surfaces.\nThe ESLint rule `craft-ts/require-exception-component-di-check` generates\n`RouteExceptionComponentCheckedDI` checks for:\n\n- global `withRouteLoadError(...)` components;\n- route-local `provideRouteLoadErrorComponent(...)` components.\n\nRun ESLint with `--fix` after adding or changing a route-load error component:\n\n```bash\nnpx nx lint your-app --fix\n```\n\nDo not hand-maintain the generated `_Check*DI` blocks.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs)\n(`assertRouteDiProofs`) fail if a registered route-load error screen has no\narmed `RouteExceptionComponentCheckedDI`.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — `withRetry` on lazy imports\n- [Global error component](/guide/routing/global-error-component)\n- [Non-blocking navigation](/guide/routing/pending-ui)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the error-screen proof armed\n"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"path": "/guide/routing/route-providers",
|
|
294
|
+
"title": "Route providers",
|
|
295
|
+
"body": "# Route providers\n\nA route can provide services built from **its own URL** — the `:userId` in the\npath, its `data`, its query params, the value its guard resolved — with full\ntype-safe dependency tracking.\n\n**Use it when** a subtree's services depend on which route rendered them: a\n\"current project\" service, a tenant-scoped API client.\n**Not when** the dependency is global — provide it at the app level instead.\n\nBuild route-level providers from a route's **own auto-provisioned tokens** — path params,\n`data`, `queryParams`, and `canActivate` guarded data — with full, type-safe dependency tracking.\n\n## The problem\n\nA `craftRoutes` route auto-provisions route-scoped services. For a route `query/:userId` in the\n`demo` collection, `craftRoutes` generates helpers such as `injectDemoUserIdParams` and the\nyieldable `DemoQueryUserIdGuardedData`.\n\nThe params helper is useful **inside a component**. Guarded data is consumed from a generator with\n`yield* DemoQueryUserIdGuardedData()`. Route `data` is intentionally not exported as a collection-level\n`inject…Data` helper; inside `withProviders`, consume it through the local `Data` generator. This\nalso lets you take the value resolved by `canActivate` and feed it into a provider that the routed\ncomponent injects.\n\n## The solution: `craftRoute(...).withProviders(...)`\n\n`craftRoute(path, definition)` authors a single route and returns a builder with a `.withProviders(...)`\nmethod. The callback receives **route-scoped service generators**, one per auto-provisioned token\nthat exists on the route, and returns a normal providers array.\n\n```ts\nimport {\n abstract,\n craftRoutes,\n craftService,\n query,\n craftRoute,\n} from '@craft-ts/core';\n\ntype User = { name: string };\n\n// 1. An abstract contract — implemented per route.\nconst { UserRequirement, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// 2. A guard that resolves the user.\nconst { Auth } = craftService({ name: 'Auth', providedIn: 'global' }, function* () {\n const auth = yield* query('auth', {\n params: () => true,\n loader: async () => ({}) as User,\n });\n return auth;\n});\n\nexport const { demoRoutes } = craftRoutes('demo', [\n craftRoute('query/:userId', {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n const user = yield* Auth();\n const userValue = user.value();\n if (!userValue) {\n return false;\n }\n return safeUser; // becomes the route's guarded data\n },\n }).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n const guarded = yield* GuardedData(); // Signal<User>\n return guarded();\n }),\n ]),\n]);\n```\n\nThe routed component can now yield `User()` from its Craft component factory and receive the value\nthat the guard resolved — without ever touching the fully-qualified route helper.\n\n## The helpers object\n\nThe `.withProviders(...)` callback receives an object with **route-local short names** for every\nauto-provisioned token present on the route:\n\n| Helper | Present when… | Yields |\n| --------------- | --------------------------- | -------------------------------------- |\n| `GuardedData` | the route has `canActivate` | `Signal<GuardData>` |\n| `<Param>Params` | per path param | `Signal<string>` (e.g. `UserIdParams`) |\n| `QueryParams` | the route has `queryParams` | the query-params state |\n| `Data` | the route has `data` | `Signal<RouteData>` |\n\nNames are **scoped to the single route**, so the collection prefix and route path are dropped:\n`GuardedData`, not `DemoQueryUserIdGuardedData`. The path-param name is kept to keep\nmultiple params distinct (`UserIdParams`, `TeamIdParams`, …).\n\nEach helper is a generator you consume with `yield*`, exactly like a service's `X()`:\n\n```ts\n.withProviders(({ UserIdParams, QueryParams }) => [\n provideSomething(function* () {\n const userId = yield* UserIdParams(); // Signal<string>\n const qp = yield* QueryParams(); // query-params state\n return { userId, qp };\n }),\n])\n```\n\n## Pairing with an abstract service\n\n`craftRoute(...).withProviders(...)` shines with `scope: 'abstract'` services. The abstract service\ndeclares a contract; each route provides a concrete implementation derived from that route's data.\n\nAbstract services now expose a `provideX(factory)` helper that takes a **generator factory**, tracks\neverything it yields, and binds the result to the requirement token. See\n[craftService → Abstract Providers](/guide/app/craft-service#abstract-providers).\n\n```ts\nconst { User, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// In a route:\n.withProviders(({ GuardedData }) => [\n provideUser(function* () {\n return (yield* GuardedData())();\n }),\n])\n\n// In the routed component factory:\nconst user = yield* User(); // User\n```\n\n## Dependency tracking & cascade DI\n\nEverything yielded inside a `withProviders` factory is tracked at the type level and folded into the\nroute's dependency graph used by [`ValidateCascadeRoutesFile`](/guide/routing/setup):\n\n- The route's **auto-provisioned** tokens (guarded data, params, query params, data) are recognized\n as provided by the route itself — yielding them is always valid.\n- Any **other** service yielded inside the factory that is not provided by the route or the app\n surfaces as a missing-provider error, e.g.:\n\n ```\n The SomeService service is not provided in path: \"query/:userId\"\n ```\n\n- The provider's own name (`User` above) is registered as **self-provided**, so a component on that\n route can depend on it without a separate provider declaration.\n\nThis means the pattern is safe by construction: you cannot wire a route provider against data the\nroute does not actually expose.\n\n## Plain providers still work\n\n`.withProviders(...)` is additive. A route can still declare a plain `providers` array, and\nboth are merged (auto-provisioned services first, then `providers`, then the `withProviders`\nfactory output):\n\n```ts\ncraftRoute('admin', {\n componentDeps: {} as import('./admin').GenDeps_Admin,\n loadComponent: ({ withRetry }) => withRetry(import('./admin')),\n providers: [SomeCraftProvider], // plain array, untyped helpers\n}).withProviders(({ Data }) => [\n /* factory-built providers with tracking */\n]);\n```\n\nUnder the hood the builder stores the factory on a dedicated `providersFn` field, kept separate from\nthe route's `providers` array.\n\n## See Also\n\n- [Setup](/guide/routing/setup) — the app-wide cascade DI check\n- [craftService](/guide/app/craft-service) — `abstract` scope, `provideX`, requirements\n"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"path": "/guide/routing/scaling",
|
|
299
|
+
"title": "Scaling routes",
|
|
300
|
+
"body": "# Scaling routes\n\n`ValidateCascadeRoutesFile` walks every route in a collection **at the type\nlevel**, so a single routes file has a finite budget before TypeScript's\ninstantiation ceiling. This page is about what happens at that ceiling, and how\nto organise routes so you never reach it.\n\n::: tip You don't need this yet\nIf your app has one routes file with a handful of routes,\n[Setup](/guide/routing/setup) is enough. Come back when a file grows past a few\ndozen routes, or when you see `TS2589`.\n:::\n\n## Large route files — the cascade DI depth limit\n\n`ValidateCascadeRoutesFile<…, typeof appRoutes>` walks **every** route in the collection at the type\nlevel. TypeScript caps how deeply it will instantiate a recursive type, so a single collection has a\n**finite route budget**. Past it (in practice a few dozen routes, sooner if routes carry guards /\n`resolve` / `handleExceptions`), the check overflows:\n\n```\nTS2589: Type instantiation is excessively deep and possibly infinite.\n app.routes.ts → ValidateCascadeRoutesFile<never, CraftRouter, typeof appRoutes>\n```\n\n::: warning Watch out for the knock-on collapse\nA `TS2589` makes TypeScript abandon that type and fall back to `any`, which **poisons inference of\nneighbouring `const`s in the same file**. The visible symptoms are misleading: `craftRoute(...)` calls\ncollapse to `RouteWithProvidersBuilder<{ path }>`, the `craftRoutes(...)` helpers go missing\n(`Property 'injectXxx' does not exist`), and `craftRouterLink` targets type as `never`. The root\ncause is the overflowing check, not those routes.\n:::\n\n**Solution — split into a lazy child collection, and keep its own DI check.** The cascade check\nreads only the _current_ collection's metadata; it does **not** descend into `loadChildren`. So move\nthe extra routes into their own `craftRoutes(...)` file and reference it via `loadChildren`. That\nkeeps the parent file under budget — **but a child collection ships with _no_ DI checking unless you\nadd one**, so re-declare the check in the child file to keep DI sound. [Architecture\ntests](/guide/testing/architecture#assertroutediproofs) fail if that child proof is missing.\n\n```ts\n// feature.routes.ts — its own lazy collection\nimport {\n craftRoutes,\n craftRoute,\n type CanRun,\n type ValidateCascadeRoutesFile,\n} from '@craft-ts/core';\nimport type { CraftRouter } from '@craft-ts/core';\n\nexport const { featureRoutes } = craftRoutes('feature', [\n craftRoute('', {\n componentDeps: {} as import('./feature').GenDeps_Feature,\n loadComponent: ({ withRetry }) => withRetry(import('./feature')),\n // guards / resolve / handleExceptions …\n }),\n]);\n\n// DI safety for THIS collection — `app.routes.ts` does NOT cover loadChildren.\n// Same parent context the parent route runs under: app-level `CraftRouter` by value,\n// no extra named providers.\ntype _CheckFeatureDI = ValidateCascadeRoutesFile<\n never,\n CraftRouter,\n typeof featureRoutes\n>;\ntype _CanRunFeature = CanRun<_CheckFeatureDI>;\n```\n\n```ts\n// app.routes.ts — a cheap loadChildren entry, outside the parent's budget\n{\n path: 'feature',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./feature.routes')).then((m) => m.featureRoutes),\n},\n```\n\nA missing provider in the child collection now surfaces as a TypeScript error **in the child file**,\nexactly like the main one:\n\n```\nThe SomeService service is not provided in path: \"\"\n```\n\nIf a single feature is itself large, repeat the split, or break one big collection into several\n`craftRoutes(...)` collections each with its own check — every check then validates a smaller slice\nand stays under the depth limit. The takeaway: **DI is always verified — never drop the check; move\nit next to the routes it covers.**\n\n### Why the budget exists (the mechanism)\n\n`ValidateCascadeRoutesFile` recurses over the route tuple **4 routes per step**, so a file of `N`\nroutes recurses to depth `N / 4`. Two distinct TypeScript ceilings are in play:\n\n- **Instantiation _depth_** (the `TS2589` \"excessively deep\" error). The 4-at-a-time unrolling is what\n fights this: it quarters the recursion depth, so the wall moves from ~50 routes to a few hundred —\n but it is still a per-file ceiling.\n- **Total instantiation _count_**. Each route pays one full `RouteCheckedDI` instantiation (walking its\n `GenDeps`, the `missingProvider` map, the parent context). The total cost is therefore roughly\n **`N × cost-per-route`**, and a route carrying guards / `resolve` / `handleExceptions` costs several\n times more than a trivial one. This is why \"a few dozen\" is only a rough figure — the real budget is\n in route-_cost_, not route-_count_.\n\n## Scaling to hundreds of routes\n\nThe split above is not a one-off patch — it is the architecture. Organise routes as a **tree of feature\nfiles joined by `loadChildren`** (which you want anyway for code-splitting):\n\n```\napp.routes.ts # \"manifest\": ~N cheap { path, loadChildren } entries\n├── billing.routes.ts # ~15–20 leaf routes + its own check\n├── admin.routes.ts # ~15–20 leaf routes + its own check\n└── reporting.routes.ts # if itself large → re-split into sub-loadChildren (level 3+)\n```\n\n- A `{ path, loadChildren }` entry has no `componentDeps`, so it is **nearly free** in the parent's\n cascade check — the manifest can list dozens of them.\n- Each feature file pays the budget for **its own leaves only**. ~500 routes ÷ ~17 per file ≈ ~30\n files; two levels are plenty, and you can nest further without limit.\n- **Every `craftRoutes(...)` file re-declares its own check** (see the iron rule above). With many\n files this is easy to forget and fails silently, so enable\n `craft-ts/require-cascade-route-di-check`.\n\n::: tip Threading the parent DI context\nThe child check's parent context (`ParentNames`, `ParentValues`) is everything provided **at its mount\npoint** — app providers **plus** every ancestor route's providers. When no ancestor adds `providers`,\nthis is just the app context (`<never, CraftRouter, …>`, as in the examples above), identical in every file.\nWhen an ancestor route _does_ add providers, re-export its cumulative context and union your own onto it:\n\n```ts\n// billing.routes.ts (mounted under a route with providers: [provideBilling()])\nexport type BillingChildNames = AppProvidedNames | 'BillingService';\nexport type BillingChildValues = AppProvidedValues;\n\n// sub-billing.routes.ts\ntype _Check = ValidateCascadeRoutesFile<\n BillingChildNames,\n BillingChildValues,\n typeof subRoutes\n>;\n```\n\nForgetting to fold in an ancestor's provider makes the child check wrong (a real missing-provider bug\nslips through, or a provided service is flagged as missing), so keep the re-export next to the route\nthat adds the providers.\n:::\n\n### Escape hatch — the `O(1)`-per-route check\n\nIf a single file genuinely must hold a large flat list (no natural `loadChildren` boundary), switch it\nfrom the aggregated `ValidateCascadeRoutesFile` to the **per-route** `RouteCheckedDI`. It validates one\ncomponent at a time with **no recursion between routes**, so it never hits the depth ceiling and scales\nto thousands of routes in one file — at the cost of one check block per component instead of one per\nfile:\n\n```ts\nimport { type CanRun, type RouteCheckedDI } from '@craft-ts/core';\n\ntype _CheckItem0 = RouteCheckedDI<\n import('./item-0').GenDeps_Item0Component,\n AppProvidedNames,\n AppProvidedValues,\n 'Item0Component'\n>;\ntype _CanRunItem0 = CanRun<_CheckItem0>;\n// …one pair per route component\n```\n\nPrefer the tree-of-`loadChildren` approach (it also lazy-loads); reach for `RouteCheckedDI` only when a\nsingle big file is unavoidable.\n\n## Pinning a lazy child to its mount path (`.withParent` + `assertChildRouteMounts`)\n\nSplitting into `loadChildren` keeps each file under budget, but nothing yet guarantees a child is wired\nunder the **right** parent route. A child whose components rely on a specific mount — its `:photoId`\nparam, a declared view-transition payload, an ancestor's `providers` — is only correct under that path.\nMount it elsewhere and its DI assumptions break silently.\n\nPin a collection to its mount path with `.withParent<ParentRoutes<'path'>>()`, then enforce it once in\nthe parent with `assertChildRouteMounts(parentRoutes)`:\n\n```ts\n// view-transitions.routes.ts — the child declares where it belongs\nimport { craftRoutes, craftRoute, type ParentRoutes } from '@craft-ts/core';\n\nexport const { viewTransitionsRoutes } = craftRoutes('viewTransitions', [\n craftRoute(':photoId', {\n componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n // …\n }),\n]).withParent<ParentRoutes<'view-transitions'>>();\n```\n\n```typescript\n// app.routes.ts — the parent enforces placement (scoped to this file)\nimport { assertChildRouteMounts, craftRoutes } from '@craft-ts/core';\n\nexport const { demoRoutes } = craftRoutes('demo', [\n {\n path: 'view-transitions',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./view-transitions.routes')).then(\n (m) => m.viewTransitionsRoutes,\n ),\n },\n]);\n\nassertChildRouteMounts(demoRoutes);\n```\n\n\n\nMount the pinned collection under any other path and the **parent file** fails to compile:\n\n```\ncraftRoutes(...).withParent<ParentRoutes<'view-transitions'>>() must be\nloadChildren-mounted under the route with path 'view-transitions', not 'admin'\n```\n\nNotes:\n\n- **Opt-in.** A collection without `.withParent` is _unpinned_ and mountable anywhere — fully backward\n compatible. Pin only the children whose placement actually matters.\n- **Scoped to the parent.** `assertChildRouteMounts` reads the parent's **own** routes (`_routes`) — it\n does **not** descend into / re-validate the child (already checked in its own file), so it adds nothing\n to the child's instantiation budget.\n- **Type-only.** `.withParent<…>()` returns the same object at runtime; `ParentRoutes<'path'>` carries no\n value, only the path string — so importing it creates no runtime coupling between the files.\n- **Enforced by ESLint.** `craft-ts/require-child-route-mount-check` adds the missing\n `assertChildRouteMounts(...)` call + import on `--fix`. (Whether a child opts in with `.withParent`\n stays your decision — it expresses the \"this belongs here\" intent the rule can't guess.)\n\n::: details Design notes — two approaches we rejected\nReaching the standalone-assert design above took two dead ends, both defeated by TypeScript's\ninstantiation ceiling. They're recorded here because the failure modes are instructive.\n\n**1. Enforcing placement inside `craftRoutes(...)` itself.** The first attempt wove the mount check into\nthe `routes` argument type of **every** `craftRoutes(...)` call, so a wrong mount would error right at\nthe route literal. It type-checked — but the extra per-collection instantiation tipped an\nalready-at-ceiling file into `TS2589`, and even a 2-route child with **no** `loadChildren` paid the cost\n(every collection runs the same inference). The lesson: the check must be **scoped to the parent that\nactually mounts children** — a standalone `assertChildRouteMounts(...)` reading the raw `_routes` — not\nfolded into the hot `craftRoutes` inference that every file pays on every build.\n\n**2. A `loadChildrenType` carrier to speed up the check.** To avoid inferring the child's type through the\ndynamic `import('./x').then((m) => m.xRoutes)`, we tried an explicit\n`loadChildrenType: {} as typeof import('./x').xRoutes` field on the lazy route. In isolation it built\nfine — but applied across the board it **materialises the child's full type (components included)**,\nwhich creates a **circular reference** for any child whose components inject the _parent's_ route data\n(`TS2615` \"circularly references itself\" + `TS2589`): `parent → typeof childRoutes → child components →\ninject parent data → parent`. Since the dynamic-import resolution it replaced was both cycle-safe and —\nonce measured against build-time noise — no slower, the carrier was dropped. `assertChildRouteMounts`\nresolves the child's pin through the existing `loadChildren` instead.\n:::\n\n## See Also\n\n- [Setup](/guide/routing/setup)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` catches a split file with no check\n- [Route providers](/guide/routing/route-providers)\n"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"path": "/guide/routing/setup",
|
|
304
|
+
"title": "Routing setup",
|
|
305
|
+
"body": "# Routing setup\n\nSix steps turn plain route definitions into routes the compiler checks: a\nmissing provider, a misspelled input or a route pointing at nothing becomes a\nbuild error instead of a blank screen. Architecture tests then keep those\nproofs from quietly disappearing.\n\n**Do this once per app**, then let [the CLI](/guide/routing/automation) write\nnew routes for you.\n\nThis guide assumes an app that consumes `@craft-ts/core`.\n\n::: tip Prefer the guided version\n[Learn step 9](/learn/09-routing) walks through the same setup on a single\nroute, with the reasoning attached.\n:::\n\n## Prerequisites\n\nInstall the runtime package and the dev tooling in your app:\n\n```bash\nnpm install @craft-ts/core\nnpm install -D @craft-ts/dev-tools\n```\n\n## 1. Add a cascade DI check to every routes file\n\nDI is checked next to the routes it covers. Every file containing `craftRoutes(...)` must pair its\ncollection with `ValidateCascadeRoutesFile` and `CanRun`; a parent check deliberately does not descend\nthrough `loadChildren`.\n\n\n\n\n`ValidateCascadeRoutesFile` compares:\n\n- the generated dependencies declared on every route\n- the providers available from the app, parent mount, route and component\n\nIf a route depends on a service that is not provided, or if a routed component expects an input that\nthe route does not supply, `_CanRunApp` turns that mismatch into a TypeScript error in the routes file.\n\nTypical errors look like:\n\n- `The Counter service is not provided in path: \"some-path\"`\n- `Input \"userId\" is not provided in path: \"some-path\"`\n\n## 2. Define routes with `craftRoute` and collect them with `craftRoutes`\n\nDo not export a plain untyped routes array directly. Define each typed route with\n`craftRoute(...)`, collect them with `craftRoutes(...)`, and declare `componentDeps` on each route\ncomponent.\n\n::: warning Breaking rename\nThe former `route(...)` helper has been renamed to `craftRoute(...)`. There is no compatibility alias:\nupdate both the import and every call site.\n:::\n\n```ts\nimport { craftRoute, craftRoutes } from '@craft-ts/core';\n\nexport const { appRoutes } = craftRoutes('app', [\n craftRoute('', {\n loadComponent: ({ withRetry }) => withRetry(import('./test')),\n componentDeps: {} as import('./test').GenDeps_TestComponent,\n }),\n]);\n```\n\nThe important part is:\n\n```ts\ncomponentDeps: {} as import('./test').GenDeps_TestComponent,\n```\n\nThat line connects the generated `GenDeps_*` type of the component to the same-file cascade check.\n\n### Prefer the route CLI for day-to-day authoring\n\nThe CLI is the primary writing façade while the generated result remains ordinary editable TypeScript:\n\n```bash\nnpx craft route add\nnpx craft route add /users/:userId --component src/app/users/user-detail.ts#UserDetailComponent\nnpx craft route add /users/:userId --create-component users/user-detail\n```\n\nBy default it detects the project and `craftRoutes` collections, creates one lazy routes file\nper feature, adds `componentDeps`, `withRetry`, `.withParent`, the parent mount assertion and the\nsame-file DI check, then runs ESLint and TypeScript diagnostics. Use `--dry-run` to inspect the plan,\n`--yes` for non-interactive scripts and `--json` for machine-readable output.\n\nStatic redirects stay in the selected collection:\n\n```bash\nnpx craft route add /old-users --redirect-to /users --parent src/app/app.routes.ts#appRoutes\n```\n\nExisting flat groups can be split explicitly:\n\n```bash\nnpx craft route split \\\n --parent src/app/app.routes.ts#appRoutes \\\n --prefix users \\\n --target src/app/users/users.routes.ts\n```\n\nThe split command only moves statically analyzable routes. It reports local declarations or dynamic\npaths without mutating files, so business logic is never guessed.\n\nThen wire the crafted routes into your application config:\n\n```ts\nimport { craftAppConfig, provideCraftRouter } from '@craft-ts/core';\nimport { appRoutes } from './app.routes';\n\nexport const appConfig = craftAppConfig({\n routingDeps: appRoutes.META_DATA,\n providers: [provideCraftRouter(appRoutes.toRoutes())],\n});\n```\n\nNotes:\n\n- `appRoutes.toRoutes()` gives the router the real runtime routes.\n- `appRoutes.META_DATA` gives `craftAppConfig(...)` the compile-time route dependency graph.\n- Route params are bound to component inputs by name; there is nothing to opt into.\n- `provideCraftRouter(...)` also takes the craft loading features\n (`withErrorComponent`, `withRouteLoadError`, `withTransitionTimings`, …) in\n the same call, e.g.\n `provideCraftRouter(appRoutes.toRoutes(), withErrorComponent({ component: MyGlobalErrorScreen }))`.\n- Render `CraftRouterOutlet()` from `@craft-ts/component` inside your Craft\n component tree: the URL commits immediately and the outlet drives the\n pending UI and centralised exception handling.\n (The features also work standalone via `provideCraftLoading(...)`.)\n `withRouteLoadError(...)` must stay in `provideCraftRouter(...)` because it also registers an\n a navigation error handler and an internal recovery route. See\n [Non-blocking navigation & pending UI](/guide/routing/pending-ui) and\n [Route Load Errors](/guide/routing/route-load-errors).\n- For lazy routes, `loadChildren` should return the named route tree exported by the child collection, for example `childRoutes.childRoutes`.\n\n### When a routes file gets big\n\nThe cascade check has a per-file budget, and past it TypeScript reports\n`TS2589` and silently degrades inference in the whole file. The fix is to split\ninto lazy child collections, each with its own check — see\n**[Scaling routes](/guide/routing/scaling)**.\n\n## 3. Generate dependency metadata\n\nAdd a script in your app:\n\n```json\n{\n \"scripts\": {\n \"craft:brand\": \"craft-brand --root src\"\n }\n}\n```\n\nThen run:\n\n```bash\nnpm run craft:brand\n```\n\nThis is the step that creates the initial `GenDeps_*` aliases in your component files, for example:\n\n```ts\nexport type GenDeps_TestComponent = GetDeps<{\n deps: {\n TaskList: GetServiceDependencies<typeof TaskList>;\n };\n provided: {};\n publicProperties: GetPublicComponentProperties<TestComponent>;\n}>;\n```\n\nAdjust `--root` to your real source root:\n\n- `src` for an application\n- `projects/my-app/src` for a workspace app\n- `libs/my-feature/src` for a library\n\nIf you use a project-level `craft-brand.config.ts`, you can extend the script:\n\n```json\n{\n \"scripts\": {\n \"craft:brand\": \"craft-brand --root src --config ./craft-brand.config.ts\"\n }\n}\n```\n\n## 4. Install the ESLint rules\n\nSeveral checks in this guide rely on code a rule generates or keeps in sync —\n`GenDeps_*` aliases, the same-file DI proof, the exhaustiveness assert. Others\nenforce the architecture itself.\n\nInstalling the plugin and the rule list is its own page:\n**[ESLint rules](/guide/routing/eslint-rules)**.\n\n## 5. When a component changes, regenerate `GenDeps` with the Quick Fix\n\nAfter changing a component's DI-related shape, refresh its generated alias.\n\nTypical triggers:\n\n- adding or removing `inject(...)`\n- changing constructor injection\n- changing component `imports`\n- changing `providers`\n- changing `viewProviders`\n\nRecommended workflow:\n\n- first generation or bulk refactor: `npm run craft:brand`\n- one file without `GenDeps_*`: run the dependency generator for the relevant source root\n- one file with `GenDeps_*`: run `eslint --fix` for the file\n- CLI alternative for one file: `eslint --fix src/app/feature/my-component.ts`\n\nImportant limits:\n\n- the Quick Fix only handles the current file\n- if you rename the component class, rerun the generator so the `GenDeps_*` alias name stays aligned\n\n:::warning\nAn Eslint error does not trigger a compilation error, so make sure to run the Quick Fix or `eslint --fix` after changing a component's DI shape. Otherwise, `main.ts` will not see the updated `GenDeps_*` and may miss real DI errors.\n:::\n\n## 6. Make the DI contract enforceable\n\nThe proofs in this guide are unused type aliases unless they stay in the file:\ncomment out a `CanRun` and the project still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nArchitecture tests close it. `assertRouteDiProofs` walks the static graph and\nfails unless every routed component — including lazy `loadChildren`\ncollections — every pending or error screen, and every `craftAppConfig` error\nsurface is hooked to an armed mapper. TypeScript still judges whether a\ndependency is provided; the architecture suite judges whether that judgement\nwas invoked.\n\nCopy the demo layout (`apps/demo/architecture/`) and add:\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nFull setup — analysis tsconfig, catalog, Nx target — is on\n[Architecture rules](/guide/testing/architecture).\n\n## See Also\n\n- [CLI automation](/guide/routing/automation) — let the CLI write routes for you\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the proofs armed\n- [Route guards](/guide/routing/guards) — the next thing you'll add\n- [Scaling routes](/guide/routing/scaling) — when one routes file gets too big\n"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"path": "/guide/state/async-process",
|
|
309
|
+
"title": "asyncProcess",
|
|
310
|
+
"body": "# asyncProcess\n\n`asyncProcess` runs an async operation and tracks its status, for work that is\nneither a server read nor a server write.\n\n**Use it when** you need to know whether something asynchronous is running: a\ndebounced search, a share sheet, a file export, a delay, a browser API call.\n**Not when** you fetch ([`query`](/guide/state/server-state)) or write\n([`mutation`](/guide/state/mutations)) — those give you caching, params\nreactivity and mutation wiring on top.\n\n## The common case\n\n```typescript\nimport { asyncProcess, craftComputed } from '@craft-ts/core';\n\nconst { delay } =\n yield *\n asyncProcess('delay', {\n method: (successResult: string) => successResult,\n loader: async ({ params: successResult }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * delay.method('success');\n\ndelay.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ndelay.isLoading();\ndelay.hasValue();\ndelay.value(); // never throws\n```\n\n::: warning `method` always takes exactly one parameter\nPass an object when you need several values.\n:::\n\n## Wrapping a browser API\n\nThis is the case `asyncProcess` exists for — turning a promise-returning native\nAPI into something with an observable status:\n\n```typescript\nconst { shareContent } =\n yield *\n asyncProcess(\n 'shareContent',\n {\n method: (payload: { title: string; url: string }) => payload,\n loader: function* ({ params }) {\n return (yield* BrowserNavigator.share(params)) as Promise<undefined>;\n },\n },\n ({ resource }) => ({\n isMenuOpen: craftComputed(function* () {\n return (yield* resource.status()) === 'loading';\n }),\n }),\n );\n\nyield * shareContent.method({ title: 'Hello AI!', url: 'https://example.com' });\nyield* shareContent.isMenuOpen();\n```\n\nYielding the browser API through a service — rather than touching `navigator`\ndirectly — is also what makes it mockable in tests. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) when the process should run on an\nevent rather than on a call — which is also where debouncing belongs:\n\n```typescript\nimport { on$, source$ } from '@craft-ts/core';\n\nconst searchSource = source$<string>('searchSource');\n\nconst { delayedSearch } =\n yield *\n asyncProcess('delayedSearch', {\n method: on$(searchSource, (term) => term),\n loader: async ({ params: term }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return term;\n },\n });\n\nsearchSource.emit('query text'); // runs automatically\n\ndelayedSearch.source; // ReadonlySource\ndelayedSearch.status();\n```\n\n## Exceptions\n\nSplit by origin, exactly like `query` and `mutation` — `params` for what\n`method` rejected, `loader` for what the operation produced:\n\n```typescript\nconst { loadUser } =\n yield *\n asyncProcess('loadUser', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'blocked'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * loadUser.method('ab');\nloadUser.hasException(); // true\nloadUser.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * loadUser.method('blocked');\nloadUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\n## Pitfalls\n\n**`method` needs its one parameter**, even when you have nothing to pass.\n\n`value()` is safe to read in templates and computed signals — it returns\n`undefined` when the process has no resolved value.\n\n**Reaching for it to fetch data.** If it's an HTTP read, `query` gives you\nreactive `params` and mutation wiring you'd otherwise rebuild by hand.\n\n::: details Advanced — parallel runs by identifier\n`identifier` keeps one resource per key so several runs coexist:\n\n```typescript\nconst { debouncedById } =\n yield *\n asyncProcess('debouncedById', {\n method: (payload: { successResult: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: async ({ params: { successResult } }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\nyield * debouncedById.method({ id: '1', successResult: data1 });\nyield * debouncedById.method({ id: '2', successResult: data2 });\n\ndebouncedById.select('1')?.value(); // data1\ndebouncedById.select('2')?.value(); // data2\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method` and `loader` can be generators, and `providers` scopes dependencies to\nthis process alone:\n\n```typescript\nconst { loadProfile } =\n yield *\n asyncProcess('loadProfile', {\n providers: [provideAsyncLogger(), provideProfileGateway()],\n method: function* (userId: string) {\n yield* AsyncLogger.log(`load:${userId}`);\n return userId;\n },\n loader: function* ({ params }) {\n return yield* ProfileGateway.load(params);\n },\n });\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectAsyncProcessMethodRuntimeContext()`, and the\nprocess value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Browser boundaries](/guide/testing/browser-boundaries) — mocking native APIs\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"path": "/guide/state/collections",
|
|
314
|
+
"title": "Collections",
|
|
315
|
+
"body": "# Collections\n\n`insertEntities` generates typed collection methods — add, remove, update,\nupsert — directly on a primitive holding an array of entities, including arrays\nnested inside an object.\n\n**Use it when** a state, query or queryParams holds a list you mutate by id.\n**Not when** the list is read-only, or when the operation concerns one nested\nbranch rather than the collection — that is\n[`insertSelect`](/guide/state/select).\n\n## Import\n\n```typescript\nimport { insertEntities } from '@craft-ts/core';\nimport {\n addOne,\n addMany,\n removeOne,\n removeMany,\n setOne,\n setMany,\n setAll,\n updateOne,\n updateMany,\n upsertOne,\n upsertMany,\n removeAll,\n} from '@craft-ts/core';\n```\n\n## Overview\n\n`insertEntities` bridges entity utility functions with reactive primitives by:\n\n- **Adding methods** - Automatically generates typed methods from entity utilities\n- **Path support** - Works with nested properties using dot notation\n- **Custom identifiers** - Supports custom ID selectors beyond default `id` property\n- **Parallel queries** - Enables entity manipulation in query instances with `select` parameter\n- **Type inference** - Full TypeScript support with automatic method name generation\n\n::: warning\nThis API currently promotes state imperative change. I am planning to improve this in the future, in order to keep state as much as I can declarative.\n:::\n\n## Entity Utilities\n\nThe following entity utility functions can be used with `insertEntities`:\n\n| Utility | Description |\n| ------------ | ----------------------------------------- |\n| `addOne` | Adds a single entity to the end |\n| `addMany` | Adds multiple entities to the end |\n| `setOne` | Replaces or adds an entity by ID |\n| `setMany` | Replaces or adds multiple entities by ID |\n| `setAll` | Replaces the entire collection |\n| `updateOne` | Partially updates an entity by ID |\n| `updateMany` | Partially updates multiple entities by ID |\n| `upsertOne` | Updates if exists, otherwise adds |\n| `upsertMany` | Updates multiple if exist, otherwise adds |\n| `removeOne` | Removes a single entity by ID |\n| `removeMany` | Removes multiple entities by ID |\n| `removeAll` | Clears the entire collection |\n\n## Signature\n\n```typescript\nfunction insertEntities<State, K, EntityHelperFns, Path>(config: {\n methods: EntityHelperFns;\n identifier?: IdSelector<Entity, K>;\n path?: Path; // For nested arrays in objects\n}): Insertion;\n```\n\n## Parameters\n\n### `methods`\n\nArray of entity utility functions to expose as methods on the state/query.\n\n### `identifier` (optional)\n\nCustom function to extract the unique identifier from entities. Defaults to:\n\n- For objects with `id` property: `(entity) => entity.id`\n- For primitives (string/number): `(entity) => entity`\n\n### `path` (optional)\n\nDot-notation path to a nested array property. When provided, method names are prefixed with the camelCase path.\n\n**Example:** `path: 'catalog.products'` → methods like `catalogProductsAddOne()`\n\n## Method Naming\n\n- **Without path**: Method names match utility function names (e.g., `addOne`, `removeMany`)\n- **With path**: Method names are prefixed with camelCase path (e.g., `productsAddOne`, `catalogProductsRemoveMany`)\n\n## The common case\n\n```typescript\nimport {\n state,\n insertEntities,\n addOne,\n addMany,\n removeOne,\n} from '@craft-ts/core';\n\nconst { tags } = state(\n 'tags',\n [] as string[],\n insertEntities({\n methods: [addOne, addMany, removeOne],\n }),\n);\n\n// Add single tag\ntags.addOne({ entity: 'typescript' });\nconsole.log(tags()); // ['typescript']\n\n// Add multiple tags\ntags.addMany({ newEntities: ['craft', 'signals'] });\nconsole.log(tags()); // ['typescript', 'craft', 'signals']\n\n// Remove tag\ntags.removeOne({ id: 'typescript' });\nconsole.log(tags()); // ['craft', 'signals']\n```\n\n\n::: details More examples — nested paths, queries, CRUD, URL state\n#### Managing objects with default ID\n\n```typescript\nimport {\n state,\n insertEntities,\n addOne,\n setOne,\n removeOne,\n} from '@craft-ts/core';\n\ninterface Product {\n id: string;\n name: string;\n price: number;\n}\n\nconst { products } = state(\n 'products',\n [] as Product[],\n insertEntities({\n methods: [addOne, setOne, removeOne],\n }),\n);\n\n// Add product\nproducts.addOne({\n entity: { id: '1', name: 'Laptop', price: 999 },\n});\n\n// Replace or update product\nproducts.setOne({\n entity: { id: '1', name: 'Laptop Pro', price: 1299 },\n});\n\nconsole.log(products()); // [{ id: '1', name: 'Laptop Pro', price: 1299 }]\n\n// Remove product\nproducts.removeOne({ id: '1' });\nconsole.log(products()); // []\n```\n\n#### Using custom identifier\n\n```typescript\nimport { state, insertEntities, setOne, removeOne } from '@craft-ts/core';\n\ninterface User {\n uuid: string;\n name: string;\n email: string;\n}\n\nconst { users } = state(\n 'users',\n [] as User[],\n insertEntities({\n methods: [setOne, removeOne],\n identifier: (user) => user.uuid,\n }),\n);\n\nusers.setOne({\n entity: { uuid: 'abc-123', name: 'Alice', email: 'alice@example.com' },\n});\n\nusers.setOne({\n entity: { uuid: 'abc-123', name: 'Alice Smith', email: 'alice@example.com' },\n});\n\nconsole.log(users());\n// [{ uuid: 'abc-123', name: 'Alice Smith', email: 'alice@example.com' }]\n\nusers.removeOne({ id: 'abc-123' });\nconsole.log(users()); // []\n```\n\n#### Working with nested arrays using path\n\n```typescript\nimport { state, insertEntities, addMany, removeOne } from '@craft-ts/core';\n\ninterface Catalog {\n total: number;\n products: Array<{ id: string; name: string }>;\n}\n\nconst { catalog } = state(\n 'catalog',\n {\n total: 0,\n products: [],\n } as Catalog,\n insertEntities({\n methods: [addMany, removeOne],\n path: 'products',\n }),\n);\n\n// Methods are prefixed with \"products\"\ncatalog.productsAddMany({\n newEntities: [\n { id: '1', name: 'Item 1' },\n { id: '2', name: 'Item 2' },\n ],\n});\n\nconsole.log(catalog());\n// { total: 0, products: [{ id: '1', name: 'Item 1' }, { id: '2', name: 'Item 2' }] }\n\ncatalog.productsRemoveOne({ id: '1' });\nconsole.log(catalog());\n// { total: 0, products: [{ id: '2', name: 'Item 2' }] }\n```\n\n#### Deep nested path with dot notation\n\n```typescript\nimport { state, insertEntities, addMany } from '@craft-ts/core';\n\ninterface State {\n catalog: {\n featured: {\n products: Array<{ id: string; name: string }>;\n };\n };\n}\n\nconst { store } = state(\n 'store',\n {\n catalog: {\n featured: {\n products: [],\n },\n },\n } as State,\n insertEntities({\n methods: [addMany],\n path: 'catalog.featured.products',\n }),\n);\n\n// Method is prefixed with camelCase: catalogFeaturedProducts\nstore.catalogFeaturedProductsAddMany({\n newEntities: [{ id: '1', name: 'Featured Item' }],\n});\n\nconsole.log(store().catalog.featured.products);\n// [{ id: '1', name: 'Featured Item' }]\n```\n\n#### Using with query primitive\n\n```typescript\nimport { query, insertEntities, addMany, removeOne } from '@craft-ts/core';\n\ninterface Product {\n id: string;\n name: string;\n}\n\nconst { productsQuery } = query(\n 'productsQuery',\n {\n params: () => 'all',\n loader: async () => {\n const response = await fetch('/api/products');\n return response.json() as Product[];\n },\n },\n insertEntities({\n methods: [addMany, removeOne],\n }),\n);\n\n// After query loads, manipulate the cached data\nawait productsQuery.load();\n\n// Add optimistic product\nproductsQuery.addMany({\n newEntities: [{ id: 'temp-1', name: 'New Product' }],\n});\n\n// Remove product from cache\nproductsQuery.removeOne({ id: 'temp-1' });\n```\n\n#### Working with parallel queries\n\n```typescript\nimport { query, insertEntities, addOne } from '@craft-ts/core';\n\nconst { userQuery } = query(\n 'userQuery',\n {\n params: () => 'userId',\n identifier: (params) => params, // Track multiple query instances\n loader: async ({ params }) => {\n const response = await fetch(`/api/users/${params}/posts`);\n return response.json();\n },\n },\n insertEntities({\n methods: [addOne],\n }),\n);\n\n// Manipulate specific query instance with select parameter\nuserQuery.addOne({\n select: 'user-123', // Target specific query instance\n entity: { id: 'post-1', title: 'New Post' },\n});\n```\n\n#### Update operations\n\n```typescript\nimport { state, insertEntities, updateOne, updateMany } from '@craft-ts/core';\n\ninterface Todo {\n id: string;\n title: string;\n completed: boolean;\n}\n\nconst { todos } = state(\n 'todos',\n [\n { id: '1', title: 'Learn Craft', completed: false },\n { id: '2', title: 'Build app', completed: false },\n ] as Todo[],\n insertEntities({\n methods: [updateOne, updateMany],\n }),\n);\n\n// Update single todo\ntodos.updateOne({\n update: {\n id: '1',\n changes: { completed: true },\n },\n});\n\nconsole.log(todos()[0].completed); // true\n\n// Update multiple todos\ntodos.updateMany({\n updates: [\n { id: '1', changes: { title: 'Learn Craft Signals' } },\n { id: '2', changes: { completed: true } },\n ],\n});\n```\n\n#### Upsert operations\n\n```typescript\nimport { state, insertEntities, upsertOne, upsertMany } from '@craft-ts/core';\n\ninterface Settings {\n key: string;\n value: string;\n}\n\nconst { settings } = state(\n 'settings',\n [{ key: 'theme', value: 'dark' }] as Settings[],\n insertEntities({\n methods: [upsertOne, upsertMany],\n identifier: (setting) => setting.key,\n }),\n);\n\n// Updates existing or adds new\nsettings.upsertOne({\n entity: { key: 'theme', value: 'light' },\n});\n\nconsole.log(settings());\n// [{ key: 'theme', value: 'light' }]\n\nsettings.upsertMany({\n newEntities: [\n { key: 'theme', value: 'auto' },\n { key: 'language', value: 'en' },\n ],\n});\n\nconsole.log(settings());\n// [\n// { key: 'theme', value: 'auto' },\n// { key: 'language', value: 'en' }\n// ]\n```\n\n#### Complete CRUD example\n\n```typescript\nimport {\n state,\n insertEntities,\n addOne,\n setOne,\n updateOne,\n removeOne,\n setAll,\n} from '@craft-ts/core';\n\ninterface Task {\n id: string;\n title: string;\n completed: boolean;\n priority: 'low' | 'medium' | 'high';\n}\n\nconst { tasks } = state(\n 'tasks',\n [] as Task[],\n insertEntities({\n methods: [addOne, setOne, updateOne, removeOne, setAll],\n }),\n);\n\n// Create\ntasks.addOne({\n entity: {\n id: '1',\n title: 'Review code',\n completed: false,\n priority: 'high',\n },\n});\n\n// Read - use tasks() to access the array\n\n// Update\ntasks.updateOne({\n update: {\n id: '1',\n changes: { completed: true },\n },\n});\n\n// Replace\ntasks.setOne({\n entity: {\n id: '1',\n title: 'Review and merge code',\n completed: true,\n priority: 'high',\n },\n});\n\n// Delete\ntasks.removeOne({ id: '1' });\n\n// Replace all\ntasks.setAll({\n newEntities: [\n { id: '2', title: 'New task', completed: false, priority: 'medium' },\n ],\n});\n```\n\n#### Using with queryParams\n\n```typescript\nimport { queryParams, insertEntities, addOne, removeOne } from '@craft-ts/core';\n\nconst { filters } = queryParams(\n 'filters',\n {\n state: {\n selectedIds: {\n fallbackValue: [] as string[],\n codec: {\n decode: (value) => value.split(',').filter(Boolean),\n encode: (value) => (value as string[]).join(','),\n },\n },\n },\n },\n insertEntities({\n methods: [addOne, removeOne],\n path: 'selectedIds',\n }),\n);\n\n// Methods update queryParams state and URL\nfilters.selectedIdsAddOne({ entity: 'item-1' });\n// URL: ?selectedIds=item-1\n\nfilters.selectedIdsAddOne({ entity: 'item-2' });\n// URL: ?selectedIds=item-1,item-2\n\nfilters.selectedIdsRemoveOne({ id: 'item-1' });\n// URL: ?selectedIds=item-2\n```\n:::\n\n## Pitfalls\n\n**Declaring every utility \"just in case\".** `methods` decides the generated API;\nlisting all twelve gives every consumer twelve methods to ignore. List what you\nuse.\n\n**Picking the wrong operation.** `add` appends blindly, `set` replaces by id,\n`upsert` does whichever applies. Choosing `addOne` where you meant `upsertOne`\nproduces duplicates that only show up with real data.\n\n**Mutating the array directly.** The generated methods are immutable updates;\nbypassing them breaks change detection.\n\n**Deep `path` values.** If the path is getting long, the state shape is probably\nthe problem — consider flattening it.\n\n## Type Safety\n\n`insertEntities` provides full type inference:\n\n```typescript\ninterface Product {\n id: string;\n name: string;\n price: number;\n}\n\nconst { products } = state(\n 'products',\n [] as Product[],\n insertEntities({\n methods: [addOne, updateOne],\n }),\n);\n\n// ✅ TypeScript knows entity must be Product\nproducts.addOne({ entity: { id: '1', name: 'Item', price: 100 } });\n\n// ❌ TypeScript error - missing required properties\nproducts.addOne({ entity: { id: '1' } });\n\n// ✅ TypeScript knows changes are Partial<Product>\nproducts.updateOne({\n update: { id: '1', changes: { price: 120 } },\n});\n\n// ❌ TypeScript error - invalid property\nproducts.updateOne({\n update: { id: '1', changes: { invalid: true } },\n});\n```\n\n## See Also\n\n- [Collection utilities](/guide/state/collections-utils) — the underlying functions\n- [Selecting a sub-state](/guide/state/select) — for one branch rather than the list\n- [Insertions](/guide/concepts/insertions)\n"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"path": "/guide/state/collections-utils",
|
|
319
|
+
"title": "Collection utilities",
|
|
320
|
+
"body": "# Collection utilities\n\nThe immutable array helpers behind [`insertEntities`](/guide/state/collections) —\n`addOne`, `updateMany`, `upsertOne` and friends. The shapes are the ones NgRx\nEntity popularised.\n\n**Use them directly when** you manipulate an array outside a primitive: inside an\n`optimisticUpdate`, a loader, or a plain computed.\n**Otherwise** let [`insertEntities`](/guide/state/collections) generate the\nmethods for you — same functions, attached to your state.\n\nThis page is a reference: scan the API list below, or jump to the\n[usage example](#usage-example).\n\n## Types\n\n### IdSelector\n\nA function type that extracts the identifier from an entity.\n\n```typescript\ntype IdSelector<T, K = string | number> = (entity: T) => K;\n```\n\n### Update\n\nA type for partial updates containing an id and the changes to apply.\n\n```typescript\ntype Update<T, K = string | number> = {\n id: K;\n changes: Partial<T>;\n};\n```\n\n## Optional Identifier\n\nFor entities that have an `id` property, the `identifier` parameter is **optional**. The functions will automatically use the `id` property.\n\nFor entities without an `id` property, you must provide a custom `identifier` function.\n\n```typescript\n// Entity with id property - identifier is optional\ninterface User {\n id: number;\n name: string;\n}\n\nconst users: User[] = [{ id: 1, name: 'Alice' }];\nremoveOne({ id: 1, entities: users }); // ✅ OK - no identifier needed\n\n// Entity without id property - identifier is required\ninterface Product {\n sku: string;\n name: string;\n}\n\nconst products: Product[] = [{ sku: 'A1', name: 'Widget' }];\nremoveOne({ id: 'A1', entities: products, identifier: (p) => p.sku }); // ✅ OK\n```\n\n## Usage Example\n\n```typescript\nimport {\n addOne,\n addMany,\n updateOne,\n removeOne,\n upsertOne,\n} from '@anthropic/craft';\n\ninterface User {\n id: number;\n name: string;\n email: string;\n}\n\nlet users: User[] = [];\n\n// Add a single user\nusers = addOne({\n entity: { id: 1, name: 'Alice', email: 'alice@example.com' },\n entities: users,\n});\n\n// Add multiple users\nusers = addMany({\n newEntities: [\n { id: 2, name: 'Bob', email: 'bob@example.com' },\n { id: 3, name: 'Charlie', email: 'charlie@example.com' },\n ],\n entities: users,\n});\n\n// Update a user (no identifier needed - User has id property)\nusers = updateOne({\n update: { id: 1, changes: { name: 'Alice Updated' } },\n entities: users,\n});\n\n// Upsert a user (update if exists, add if not)\nusers = upsertOne({\n entity: { id: 4, name: 'David', email: 'david@example.com' },\n entities: users,\n});\n\n// Remove a user\nusers = removeOne({ id: 2, entities: users });\n```\n\n## API reference\n\n### removeAll\n\nRemoves all elements from the list.\n\n```typescript\nfunction removeAll<T>(): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\nconst result = removeAll<User>(); // []\n```\n\n---\n\n### addOne\n\nAdds an element to the end of the list.\n\n```typescript\nfunction addOne<T>({ entity, entities }: { entity: T; entities: T[] }): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\nconst result = addOne({\n entity: { id: 2, name: 'Bob' },\n entities: users,\n});\n// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]\n```\n\n---\n\n### addMany\n\nAdds multiple elements to the end of the list.\n\n```typescript\nfunction addMany<T>({\n newEntities,\n entities,\n}: {\n newEntities: T[];\n entities: T[];\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\nconst result = addMany({\n newEntities: [\n { id: 2, name: 'Bob' },\n { id: 3, name: 'Charlie' },\n ],\n entities: users,\n});\n// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }]\n```\n\n---\n\n### setAll\n\nReplaces the entire list with new elements.\n\n```typescript\nfunction setAll<T>({ newEntities }: { newEntities: T[] }): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\nconst result = setAll({ newEntities: [{ id: 2, name: 'Bob' }] });\n// [{ id: 2, name: 'Bob' }]\n```\n\n---\n\n### setOne\n\nReplaces an element if it exists (based on id), otherwise adds it.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\n// With identifier (required for entities without id property)\nfunction setOne<T, K = string | number>(params: {\n entity: T;\n entities: T[];\n identifier: IdSelector<T, K>;\n}): T[];\n\n// Without identifier (for entities with id property)\nfunction setOne<T extends { id: K }, K>(params: {\n entity: T;\n entities: T[];\n identifier?: IdSelector<T, K>;\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\n\n// Without identifier (User has id property)\nconst result1 = setOne({\n entity: { id: 1, name: 'Alice Updated' },\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated' }]\n\n// With custom identifier\nconst products = [{ sku: 'A1', name: 'Widget' }];\nconst result2 = setOne({\n entity: { sku: 'A1', name: 'Widget Updated' },\n entities: products,\n identifier: (p) => p.sku,\n});\n// [{ sku: 'A1', name: 'Widget Updated' }]\n```\n\n---\n\n### setMany\n\nReplaces or adds multiple elements (based on id).\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction setMany<T, K = string | number>(params: {\n newEntities: T[];\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\n\n// Without identifier\nconst result = setMany({\n newEntities: [\n { id: 1, name: 'Alice Updated' },\n { id: 2, name: 'Bob' },\n ],\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }]\n```\n\n---\n\n### updateOne\n\nPartially updates an existing element. Does nothing if the element is not found.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction updateOne<T, K = string | number>(params: {\n update: Update<T, K>;\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];\n\n// Without identifier\nconst result = updateOne({\n update: { id: 1, changes: { name: 'Alice Updated' } },\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated', email: 'alice@example.com' }]\n```\n\n---\n\n### updateMany\n\nPartially updates multiple existing elements.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction updateMany<T, K = string | number>(params: {\n updates: Update<T, K>[];\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' },\n];\n\n// Without identifier\nconst result = updateMany({\n updates: [\n { id: 1, changes: { name: 'Alice Updated' } },\n { id: 2, changes: { name: 'Bob Updated' } },\n ],\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob Updated' }]\n```\n\n---\n\n### upsertOne\n\nUpdates an element if it exists (merging properties), otherwise adds it.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction upsertOne<T, K = string | number>(params: {\n entity: T;\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];\n\n// Update existing (merges properties) - without identifier\nconst result1 = upsertOne({\n entity: { id: 1, name: 'Alice Updated' },\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated', email: 'alice@example.com' }]\n\n// Add new\nconst result2 = upsertOne({\n entity: { id: 2, name: 'Bob' },\n entities: users,\n});\n// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]\n```\n\n---\n\n### upsertMany\n\nUpdates multiple elements if they exist, otherwise adds them.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction upsertMany<T, K = string | number>(params: {\n newEntities: T[];\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [{ id: 1, name: 'Alice' }];\n\n// Without identifier\nconst result = upsertMany({\n newEntities: [\n { id: 1, name: 'Alice Updated' },\n { id: 2, name: 'Bob' },\n ],\n entities: users,\n});\n// [{ id: 1, name: 'Alice Updated' }, { id: 2, name: 'Bob' }]\n```\n\n---\n\n### removeOne\n\nRemoves an element by its id.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction removeOne<T, K = string | number>(params: {\n id: K;\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' },\n];\n\n// Without identifier\nconst result = removeOne({ id: 1, entities: users });\n// [{ id: 2, name: 'Bob' }]\n\n// With custom identifier for entities without id\nconst products = [{ sku: 'A1', name: 'Widget' }];\nconst result2 = removeOne({\n id: 'A1',\n entities: products,\n identifier: (p) => p.sku,\n});\n// []\n```\n\n---\n\n### removeMany\n\nRemoves multiple elements by their ids.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction removeMany<T, K = string | number>(params: {\n ids: K[];\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' },\n { id: 3, name: 'Charlie' },\n];\n\n// Without identifier\nconst result = removeMany({ ids: [1, 2], entities: users });\n// [{ id: 3, name: 'Charlie' }]\n```\n\n---\n\n### map\n\nApplies a transformation function to all elements.\n\n```typescript\nfunction map<T>({\n mapFn,\n entities,\n}: {\n mapFn: (entity: T) => T;\n entities: T[];\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'alice' },\n { id: 2, name: 'bob' },\n];\nconst result = map({\n mapFn: (u) => ({ ...u, name: u.name.toUpperCase() }),\n entities: users,\n});\n// [{ id: 1, name: 'ALICE' }, { id: 2, name: 'BOB' }]\n```\n\n---\n\n### mapOne\n\nApplies a transformation function to a single element by its id.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction mapOne<T, K = string | number>(params: {\n id: K;\n mapFn: (entity: T) => T;\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): T[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'alice' },\n { id: 2, name: 'bob' },\n];\n\n// Without identifier\nconst result = mapOne({\n id: 1,\n mapFn: (u) => ({ ...u, name: u.name.toUpperCase() }),\n entities: users,\n});\n// [{ id: 1, name: 'ALICE' }, { id: 2, name: 'bob' }]\n```\n\n---\n\n### computedTotal\n\nReturns the total count of entities.\n\n```typescript\nfunction computedTotal<T>({ entities }: { entities: T[] }): number;\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' },\n];\nconst total = computedTotal({ entities: users });\n// 2\n```\n\n---\n\n### computedIds\n\nReturns all ids from the entities list.\n\nIf the entity has an `id` property, the `identifier` is optional.\n\n```typescript\nfunction computedIds<T, K = string | number>(params: {\n entities: T[];\n identifier?: IdSelector<T, K>; // Optional if T has id\n}): K[];\n```\n\n**Example:**\n\n```typescript\nconst users = [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' },\n];\n\n// Without identifier\nconst ids = computedIds({ entities: users });\n// [1, 2]\n\n// With custom identifier\nconst products = [{ sku: 'A1', name: 'Widget' }];\nconst skus = computedIds({\n entities: products,\n identifier: (p) => p.sku,\n});\n// ['A1']\n```\n\n## See Also\n\n- [Collections](/guide/state/collections) — generating these as methods on a primitive\n- [Reacting to mutations](/guide/state/react-on-mutation) — the usual place to call them by hand\n"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"path": "/guide/state/local-state",
|
|
324
|
+
"title": "Local state",
|
|
325
|
+
"body": "# Local state\n\n`state` holds a value you own, in memory, as a signal — with its methods and\nderived values attached to it rather than scattered around it.\n\n**Use it when** the value's home is your application: a form draft, a selection,\na toggle, a counter.\n**Not when** the value lives on a server ([`query`](/guide/state/server-state)),\nin the URL ([`queryParams`](/guide/state/url-state)), or is the result of an\nasync action ([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst counter = yield* state('counter', 0, ({ state, update, set }) => ({\n increment: () => update((value) => value + 1),\n decrement: () => update((value) => value - 1),\n reset: () => set(0),\n isEven: craftComputed(function* () {\n return (yield* state()) % 2 === 0;\n }),\n}));\n\nyield* counter(); // 0\nyield* counter.increment();\nyield* counter.isEven(); // false\nyield* counter.reset();\n```\n\nThe insertion context gives you `state` (the current value as a yieldable\nreader), `set` and `update`. Non-generator methods may return `update(...)`\ndirectly — the insertion wrapper consumes the write. `isEven` yields `state()`\nbecause the computed does not own that reader. In a template, pass the reader\nor the method: `p(counter)`, `button({ click: counter.increment }, '+')`. At a\nsynchronous boundary, `craftUse(counter.increment())`.\n\n::: tip New to the shape?\nThe name, the destructuring, the `yield*` driver and the single-use rule are\nthe same for all five primitives — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy).\n:::\n\n## Deriving from another reader\n\nThe initial value can be a Craft reader, in which case the state follows it:\n\n```typescript\nconst origin = yield* state('origin', 5);\n\nconst doubled = yield* state(\n 'doubled',\n craftComputed('originDoubled', function* () {\n return (yield* origin()) * 2;\n }),\n);\n\nyield* doubled(); // 10\n```\n\n## Composing several insertions\n\nOne insertion function gets crowded. Split it and compose with `insertStatePipe`:\n\n```typescript\nimport { craftComputed, insertStatePipe, state } from '@craft-ts/core';\n\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((current) => current + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n\nyield* counter.increment();\nyield* counter.isOdd(); // true\n```\n\nEach function receives the same context and contributes its own slice. See\n[Insertions](/guide/concepts/insertions).\n\n## Driving it from events\n\nBind a method to a [`source$`](/guide/reactivity/source) with\n[`on$`](/guide/reactivity/on) when the trigger is an event rather than a call:\n\n```typescript\nconst increment = source$<void>('increment');\nconst reset = source$<void>('reset');\n\nconst myState = yield* state('myState', 0, ({ update, set }) => ({\n onIncrement: on$(increment, () => update((v) => v + 1)),\n onReset: on$(reset, () => set(0)),\n}));\n\nincrement.emit(); // after yield* / craftUse, myState is 1\nreset.emit(); // after yield* / craftUse, myState is 0\n```\n\nLike every craft primitive, a source is **named**, and the name must match the\nvariable it is assigned to — the `craft-ts/craft-source-name-match` ESLint rule\nenforces it and autofixes it.\n\nNote that `onIncrement` and `onReset` are **not** exposed on `myState`. Methods\nbound to a source work internally only.\n\n## Yielding dependencies\n\nAn insertion can be a `function*`, so it can pull in services:\n\n```typescript\nyield* state('counter', 0, function* ({ state }) {\n const log = yield* Console.log;\n return {\n logValue: function* () {\n yield* log(`State value: ${yield* state()}`);\n },\n };\n });\n```\n\nPrefer yielding a craft service over reaching into a runtime container — yielding is\nwhat makes the dependency visible to the route DI check and to test registers.\n\n## Pitfalls\n\n**Don't duplicate derived state.** If a value is a function of another, it is a\n`craftComputed` inside an insertion that `yield*`s its readers, or a `state`\nwhose second argument is that source — not a second `state` kept in sync by an\neffect. [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync)\nfails the architecture suite when an effect writes another primitive.\n\n**Keep slices granular.** One `state` per coherent concern. A single object\nholding five unrelated things makes every consumer depend on all five.\n\n::: details Advanced — scoping providers to one state\nUse the object form with `$self` when a state needs its own provider scope:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n {\n $self: function* () {\n return yield* CounterPreferences.initialValue();\n },\n providers: [provideCounterPreferences(), provideCounterAnalytics()],\n },\n ({ update }) => ({\n increment: function* () {\n yield* CounterAnalytics.track('increment');\n return yield* update((value) => value + 1);\n },\n }),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods also provide `injectStateMethodRuntimeContext()`, which\nrecovers `get`, `set`, `update`, and `patch` from DI. Use it from wrappers,\nWebMCP tools, and other advanced patterns — everyday insertions already\nreceive those methods as arguments. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Insertions](/guide/concepts/insertions)\n- [craftService](/guide/app/craft-service) — packaging state behind a reusable boundary\n"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"path": "/guide/state/mutations",
|
|
329
|
+
"title": "Mutations",
|
|
330
|
+
"body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\nInside `craftMutations(...)`, `providers` stays on each `mutation(name, ...)`\nconfig, not on the wrapper:\n\n```typescript\nconst userFeature = craft(\n { name: 'userFeature', providedIn: 'root' },\n craftMutations(() => ({\n saveUser: mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n }).saveUser,\n })),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"path": "/guide/state/pagination-placeholder",
|
|
334
|
+
"title": "Pagination placeholders",
|
|
335
|
+
"body": "# Pagination placeholders\n\n`insertPaginationPlaceholderData` keeps the previous page on screen while the\nnext one loads, so paging through a list never flashes an empty state.\n\n**Use it when** a query is paginated with an `identifier` per page.\n**Not when** you just want to avoid a flicker on a non-paginated query — a query\nalready keeps its previous value while loading, with no configuration\n([query](/guide/state/server-state)).\n\n```typescript\nimport { insertPaginationPlaceholderData } from '@craft-ts/core';\n```\n\n## The common case\n\nIt is a **higher-order insertion**: call it with a config and pass the result to\n`query`. `config.initialValue` is both the default value and the page type —\nwhich is why `currentPageData` is a `Signal<T>` that is **never `undefined`**.\n\n```typescript\nconst pagination = yield* state('pagination', 1);\n\nconst { userQuery } = yield* query(\n 'userQuery',\n {\n params: pagination,\n identifier: (params) => '' + params,\n loader: function* ({ params }) {\n const response = yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users?page=${params}`,\n success: response<User[]>(),\n }));\n return response.json();\n },\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n);\n\n// Access the current page data (or placeholder data during loading)\nconst data = userQuery.currentPageData();\n\n// Check the loading status of the current page\nconst status = userQuery.currentPageStatus();\n\n// Determine if placeholder data is being shown\nconst isPlaceholder = userQuery.isPlaceHolderData();\n\n// Get the current page identifier\nconst identifier = userQuery.currentIdentifier();\n```\n\n## Returned Properties\n\n| Property | Type | Description |\n| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `currentPageData` | `Signal<T>` | The data for the current page, or placeholder data from the previous page during loading. Falls back to `initialValue` (never `undefined`). |\n| `currentPageStatus` | `Signal<ResourceStatus>` | The loading status of the current page (`'idle'`, `'loading'`, `'resolved'`, `'error'`) |\n| `isPlaceHolderData` | `Signal<boolean>` | `true` when showing previous page data as a placeholder |\n| `currentIdentifier` | `Signal<string>` | The identifier of the current page |\n\n## Custom Outputs (`build` callback)\n\nPass an optional second argument to attach your own computed values or methods next to\nthe pagination outputs. Its helpers (`state`, `set`, `update`, `patch`) are scoped to the\n**current page** (the displayed data), so mutations only affect the page the user is\nlooking at — other cached pages are left untouched.\n\n```typescript\nconst { usersQuery } = query(\n 'usersQuery',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertPaginationPlaceholderData(\n { initialValue: [] as Data[] },\n ({ state, settledState, set }) => ({\n // a computed derived from the current page\n totalOfUnCompletedData: craftComputed(function* () {\n return (yield* state()).filter((d) => !d.completed).length;\n }),\n settledCount: craftComputed(function* () {\n return (yield* settledState()).length;\n }),\n markAsCompleted: function* (id: string) {\n const current = yield* state();\n return yield* set(\n current.map((d) => (d.id === id ? { ...d, completed: true } : d)),\n );\n },\n }),\n ),\n);\n\nyield* usersQuery.totalOfUnCompletedData(); // number\nyield* usersQuery.markAsCompleted('42');\n```\n\nThe `build` context exposes:\n\n| Helper | Type | Description |\n| -------- | --------------------------------------- | ---------------------------------------------------- |\n| `state` | yieldable reader for `T` | The current page data (or `initialValue`) |\n| `settledState` | generator reader for `T` | The current page data only when loaded; suspends during the first load or a page transition |\n| `set` | yieldable write returning `T` | Replace the current page data (no-op if not loaded) |\n| `update` | yieldable write returning `T` | Update the current page data from its previous value |\n| `patch` | yieldable write returning `T` | Patch the current page data with a partial value |\n\nThe pagination outputs (`currentPageData`, `currentPageStatus`, `isPlaceHolderData`,\n`currentIdentifier`) are also available in the `build` context.\n\n::: details A full paginated component\n\n```typescript\nimport { button, craftComponent, div, each, ifBlock, span } from '@craft-ts/component';\nimport { craftComputed, query, state } from '@craft-ts/core';\n\nexport const UsersList = craftComponent(\n 'UsersList',\n {},\n function* () {\n const page = yield* state('page', 1, ({ state, update, set }) => ({\n next: () => update((value) => value + 1),\n previous: function* () {\n const current = yield* state();\n return yield* set(Math.max(1, current - 1));\n },\n isFirst: craftComputed(function* () {\n return (yield* state()) === 1;\n }),\n label: craftComputed(function* () {\n return `Page ${yield* state()}`;\n }),\n }));\n\n const userQuery = yield* query(\n 'userQuery',\n {\n params: page,\n identifier: (page) => `page-${page}`,\n loader: async ({ params }) =>\n (await fetch(`/api/users?page=${params}`)).json() as Promise<User[]>,\n },\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n );\n\n return { page, userQuery };\n },\n ({ page, userQuery }) => [\n div(\n {\n class: function* () {\n return (yield* userQuery.isPlaceHolderData())\n ? 'users-list loading'\n : 'users-list';\n },\n },\n each(\n userQuery.currentPageData,\n { track: (user) => user.id },\n (user) => UserCard({ user }),\n ),\n ),\n\n div({ class: 'pagination' }, [\n button({ click: page.previous, disabled: page.isFirst }, 'Previous'),\n span(page.label),\n button({ click: page.next }, 'Next'),\n ]),\n\n ifBlock(userQuery.isPlaceHolderData, () =>\n div({ class: 'loading-indicator' }, 'Loading new page…'),\n ),\n ],\n);\n```\n\n:::\n\n## How it works\n\n1. When the page parameters change, the insertion checks whether the new page's\n data is already cached.\n2. If the new page is loading and has no data yet, it serves the previous page's\n data as a placeholder.\n3. `isPlaceHolderData` tells you that is what is on screen — use it to dim the\n list or show a spinner.\n4. Once the real data arrives, it switches over automatically.\n\n## Pitfalls\n\n**It needs an `identifier`.** Without one page identity, there is no \"previous\npage\" to fall back to.\n\n**`initialValue` defines the page type.** Passing `[]` untyped collapses\n`currentPageData` to `never[]` — write `[] as User[]`.\n\n**Mutating through the `build` helpers only affects the current page.** Other\ncached pages are untouched, which is usually what you want, but means a global\nchange needs a reload.\n\n## See Also\n\n- [query](/guide/state/server-state) — the base primitive\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Insertions](/guide/concepts/insertions)\n"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"path": "/guide/state/persistence",
|
|
339
|
+
"title": "Persistence",
|
|
340
|
+
"body": "# Persistence\n\n`insertStoragePersister` saves a primitive's value through the configured storage backend and\nrestores it on the next visit — with expiry, background revalidation and a\nvalidation hook, so stale or corrupt entries don't leak into your app.\n\n**Use it when** a value should survive a reload: a draft, a preference, a list\nyou'd rather show instantly than fetch again.\n**Not when** the value is sensitive, or when it must be correct rather than fast\n— a restored value is by definition a value from the past.\n\nWorks with `state()`, `query()`, `mutation()` and `asyncProcess()`.\n\n```typescript\nimport { craftUnique, insertStoragePersister } from '@craft-ts/core';\n```\n\nConfigure the storage backend once in `appConfig`. The default application\nselection remains `localStorage`; a child route, feature or test can select\n`sessionStorage` instead.\n\n```typescript\nimport {\n LocalStoragePersister,\n SessionStoragePersister,\n provideLocalStoragePersister,\n provideSessionStoragePersister,\n provideStoragePersister,\n} from '@craft-ts/core';\n\nproviders: [\n provideLocalStoragePersister(),\n provideSessionStoragePersister(),\n provideStoragePersister(function* () {\n return yield* LocalStoragePersister();\n }),\n];\n```\n\nThe `StoragePersister` provider is required by `craftAppConfig` and follows\nthe normal Craft DI hierarchy. A child route, feature or test can override\nthe active backend:\n\n```typescript\nproviders: [\n provideStoragePersister(function* () {\n return yield* SessionStoragePersister();\n }),\n];\n```\n\n## The common case\n\n```typescript\nconst { myState } = state(\n 'myState',\n 0,\n insertStoragePersister(craftUnique({\n storeName: 'myApp',\n key: 'myState',\n })),\n);\n\nconst { myQuery } = query(\n 'myQuery',\n {\n params: () => 'test',\n loader: async () => {\n return { data: 'testData' };\n },\n },\n insertStoragePersister(craftUnique({\n storeName: 'myApp',\n key: 'myQuery',\n })),\n);\n```\n\n## Options\n\nThe identity (`storeName` + `key`) is the first argument, wrapped in `craftUnique` so the static graph can guarantee it appears only once. Options are the second argument.\n\n| Option | Type | Default | Description |\n| ------------------------------------------ | ----------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `cacheTime` | `number` | `300000` | Time in ms after which cached data is deleted from the configured storage backend (garbage collection). Set to `0` to disable expiration. |\n| `staleTime` | `number` | `undefined` | Time in ms after which cached data is considered stale. The cached value is still restored immediately, but a background `reload()` is triggered (SWR pattern). Must be less than `cacheTime`. |\n| `validate` | `(value: unknown) => boolean` | `undefined` | Called on the deserialized value before restoring it. Return `false` to discard the entry and load fresh. Useful when the data model has changed. |\n| `waitForParamsSrcToBeEqualToPreviousValue` | `boolean` | `true` | If `true`, waits for the params signal to stabilize before trying to restore the cache. Useful when params start as `undefined`. Not applicable to `state()`. |\n\n## cacheTime vs staleTime\n\n| | Data deleted? | Reload triggered? |\n| ------------------------ | ------------------------------------- | ------------------------------ |\n| **`cacheTime`** exceeded | Yes — entry removed from the configured backend | No |\n| **`staleTime`** exceeded | No — data is still restored | Yes — `reload()` in background |\n\n`cacheTime` always takes priority: if `cacheTime` is exceeded, the entry is discarded entirely, regardless of `staleTime`.\n\n## SWR Pattern (staleTime)\n\nUse `staleTime` to display cached data immediately while silently refreshing in the background — the same pattern used by SWR and TanStack Query.\n\n```typescript\nconst { userQuery } = query(\n 'userQuery',\n {\n params: () => currentUserId(),\n loader: async ({ params }) => fetchUser(params),\n },\n insertStoragePersister(craftUnique({\n storeName: 'myApp',\n key: 'user',\n }), {\n cacheTime: 10 * 60_000,\n // delete from the configured backend after 10 min\n staleTime: 60_000,\n // show cached + reload in background after 1 min,\n }),\n);\n\n// On page load:\n// - If cache is < 1 min old → status: 'local', no reload\n// - If cache is 1–10 min old → status: 'loading', value still visible (SWR)\n// - If cache is > 10 min old → entry deleted, loads fresh\n```\n\n## Validation\n\nUse `validate` to guard against corrupt or outdated data in the configured storage backend (e.g. after a model change or manual user edit). Works with Zod or any type guard.\n\n```typescript\nimport { z } from 'zod';\n\nconst UserSchema = z.object({ id: z.string(), name: z.string() });\ntype User = z.infer<typeof UserSchema>;\n\nconst { userQuery } = query(\n 'userQuery',\n {\n params: () => currentUserId(),\n loader: async ({ params }) => fetchUser(params),\n },\n insertStoragePersister(craftUnique({\n storeName: 'myApp',\n key: 'user',\n }), {\n validate: (v): v is User => UserSchema.safeParse(v).success,\n }),\n);\n\n// If the stored value fails validation → entry is discarded, resource loads fresh\n// If it passes → restored normally\n```\n\n## Parallel resources\n\nWith `query(name, { identifier })`, each instance is cached individually under\nits identifier — no extra configuration:\n\n```typescript\nconst postsQuery = yield* query(\n 'postsQuery',\n {\n params: () => currentPostId(),\n identifier: (id) => id,\n loader: async ({ params }) => fetchPost(params),\n },\n insertStoragePersister(craftUnique({\n storeName: 'myApp',\n key: 'posts',\n }), {\n cacheTime: 15 * 60_000,\n staleTime: 2 * 60_000,\n }),\n);\n```\n\n## Pitfalls\n\n**`staleTime` must be smaller than `cacheTime`.** Otherwise the entry is deleted\nbefore it ever gets a chance to be revalidated.\n\n**A shipped model change invalidates nothing by itself.** Users carry the old\nshape in their configured storage backend. Use `validate` — that is what it is for.\n\n**Restoring a value is not the same as having loaded it.** Check\n`isPlaceHolderData` / the status before treating a restored value as fresh.\n\n::: details Managing stored data globally\nClearing, inspecting or migrating persisted entries across the whole app goes\nthrough [GlobalPersisterHandler](/guide/state/persistence-handler). It delegates\nto the active `StoragePersister`, so the built-in localStorage and\nsessionStorage backends clear their own persisted entries.\n:::\n\n## See Also\n\n- [GlobalPersisterHandler](/guide/state/persistence-handler)\n- [query](/guide/state/server-state)\n- [Insertions](/guide/concepts/insertions) — composing with other insertions\n- [Architecture rules](/guide/testing/architecture) — `assertCraftUnique` on storage identities, `assertPersistedPrimitiveHasUnique` when a persister has no identity\n"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"path": "/guide/state/persistence-handler",
|
|
344
|
+
"title": "GlobalPersisterHandler",
|
|
345
|
+
"body": "# GlobalPersisterHandler\n\nClears everything `@craft-ts` has persisted through the active\n`StoragePersister`, in one call.\n\n**Use it when** cached data must not outlive a session boundary: logout,\nswitching accounts, a \"reset the app\" action.\n**Not when** you want to invalidate one resource — reload that query, or give it\na shorter `cacheTime` in [Persistence](/guide/state/persistence).\n\n::: danger It clears everything\nThere is no per-key variant. Every persisted query, mutation and async process\ngoes.\n:::\n\n```typescript\nimport {\n GlobalPersisterHandlerService,\n provideGlobalPersisterHandlerService,\n} from '@craft-ts/core';\n\nproviders: [provideGlobalPersisterHandlerService()];\n```\n\n## How it works\n\nThe handler delegates to the active `StoragePersister`. The built-in\nlocalStorage and sessionStorage implementations remove every key that starts\nwith the `craft-ts-` prefix from their respective backend. This ensures\ncomplete cleanup of all data cached by `@craft-ts`, including:\n\n- Persisted queries\n- Persisted mutations\n- Persisted async processes\n- Any other data cached by the `@craft-ts` persistence layer\n\n## The common case — clearing on logout\n\n\n\n\n## Force refresh all data\n\n```typescript\nconst { CacheActions } = craftService(\n { name: 'CacheActions', providedIn: 'toProvide' },\n function* () {\n const persister = yield* GlobalPersisterHandlerService();\n return { clearCache: () => persister.clearAllCache() };\n },\n);\n```\n\n## Clear cache when switching accounts\n\n\n\n\n::: details Other situations where this comes up\n\n### 1. User Logout\n\nRemove all user-specific cached data when a user logs out to prevent data leakage to the next user.\n\n```typescript\nlogout() {\n this.persisterHandler.clearAllCache();\n this.authService.logout();\n}\n```\n\n### 2. Privacy Compliance\n\nEnsure no sensitive data remains in the selected storage backend after a user\nsession ends.\n\n```typescript\nngOnDestroy() {\n if (this.isPrivateMode) {\n this.persisterHandler.clearAllCache();\n }\n}\n```\n\n### 3. Development/Testing\n\nQuickly clear all cached data during development or testing.\n\n```typescript\nresetCache() {\n if (environment.development) {\n this.persisterHandler.clearAllCache();\n console.log('Cache cleared');\n }\n}\n```\n\n### 4. Data Corruption Recovery\n\nClear potentially corrupted cached data and force fresh data loading.\n\n```typescript\nhandleDataError() {\n this.persisterHandler.clearAllCache();\n this.showMessage('Cache cleared. Please refresh the page.');\n}\n```\n\n:::\n\n## See Also\n\n- [Local Storage Persister](/guide/state/persistence)\n- [Query](/guide/state/server-state)\n- [Mutation](/guide/state/mutations)\n"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"path": "/guide/state/react-on-mutation",
|
|
349
|
+
"title": "Reacting to mutations",
|
|
350
|
+
"body": "# Reacting to mutations\n\n`insertReactOnMutation` declares the link between a write and the reads it\naffects: patch the query optimistically, reload it, or both — without calling\n`refetch()` from the mutation's call site.\n\n**Use it when** a mutation makes some query stale.\n**Not when** the two are unrelated — a reaction that fires on every write is just\na hidden coupling.\n\n```typescript\nimport { insertReactOnMutation } from '@craft-ts/core';\n```\n\n## The common case\n\n```typescript\nconst updateUser = yield* mutation('updateUser', {\n method: (user: User) => user,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.patch(({ response }) => ({\n url: `/api/users/${user.id}`,\n body: user,\n success: response<User>(),\n }));\n },\n});\n\nconst queryRef = yield* query(\n 'queryRef',\n {\n params: () => '5',\n loader: async ({ params }) => ({ id: params, name: 'John' }),\n },\n insertReactOnMutation(updateUser, {\n patch: {\n name: ({ mutationParams: { name } }) => name,\n },\n }),\n);\n```\n\nThree levers, combinable:\n\n| Option | Effect |\n| ------------------ | ------------------------------------------------------------ |\n| `patch` | Apply a field-by-field change once the mutation resolves |\n| `optimisticPatch` | Apply it **immediately**, before the server answers |\n| `optimisticUpdate` | Same, but you compute the whole new value |\n| `reload` | Re-run the loader — `onMutationSuccess` / `onMutationException` / `onMutationResolved` |\n| `filter` | Only react when this predicate passes |\n\nThe usual pairing is an optimistic change plus\n`reload: { onMutationException: true }` — show the result instantly, and go get\nthe truth back if the write failed.\n\n## Targeting the right parallel query\n\nWith `identifier`, several query instances coexist. Use `filter` so the reaction\nonly touches the one the mutation concerns:\n\n```typescript\nconst queryRef = yield* query(\n 'queryRef',\n {\n params: userId,\n identifier: (userId) => userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n },\n insertReactOnMutation(updateUser, {\n filter: ({ queryIdentifier, mutationParams }) =>\n mutationParams.id === queryIdentifier,\n patch: {\n name: ({ mutationParams: { name } }) => name,\n },\n }),\n);\n```\n\n## Several reactions on one query\n\nA query accepts a single insertion, so compose them with\n[`insertQueryPipe`](/guide/concepts/insertion-pipes) to keep this composition\nreadable:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst { users } = query(\n 'users',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertQueryPipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'users',\n })),\n insertReactOnMutation(deleteUser, {\n filter: ({ mutationIdentifier, queryResource }) =>\n !!queryResource.value()?.some((u) => u.id === mutationIdentifier),\n optimisticUpdate: ({ queryResource, mutationIdentifier }) =>\n removeOne({\n entities: queryResource.value(),\n id: mutationIdentifier,\n }),\n reload: { onMutationException: true },\n }),\n insertReactOnMutation(deleteUser, {\n // reload the current page when it becomes empty\n filter: ({ queryResource }) => queryResource.value()?.length === 0,\n reload: { onMutationResolved: true },\n }),\n insertReactOnMutation(bulkDelete, {\n filter: ({ queryResource }) =>\n (queryResource.value()?.length ?? 0) > 0,\n optimisticUpdate: ({ queryResource, mutationParams }) =>\n removeMany({ entities: queryResource.value(), ids: mutationParams }),\n }),\n ),\n);\n```\n\n\n## Pitfalls\n\n**Optimistic without a fallback.** `optimisticPatch` / `optimisticUpdate` show a\nchange that has not happened yet. Pair them with\n`reload: { onMutationException: true }` so a failed write is corrected rather\nthan silently left on screen.\n\n**Forgetting `filter` on parallel queries.** Without it, a mutation on one entity\npatches every cached instance.\n\n`queryResource.value()` returns `undefined` when the query is in exception;\nhandle that case inside a `filter`.\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Mutations](/guide/state/mutations) — the write side\n- [Insertions](/guide/concepts/insertions) — composing several reactions\n- [Architecture rules](/guide/testing/architecture) — `assertMutationHasReactOn` flags a mutation no query reacts to\n"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"path": "/guide/state/schema-validation",
|
|
354
|
+
"title": "Schema validation",
|
|
355
|
+
"body": "# Schema validation\n\nPrimitives accept any schema implementing `StandardSchemaV1`, so Zod, Valibot,\nArkType, Effect Schema or a hand-written schema all work — and none of them\nbecomes a dependency of `@craft-ts`. (Effect needs one conversion call; see\n[Effect Schema](#effect-schema).)\n\n**Use it when** data crosses a boundary you don't control: a method argument, a\nserver response, a restored value.\n**Not when** the value never leaves your own typed code — TypeScript covers\nthat already.\n\n## Resource schemas\n\nResource schemas correspond to different configurations. They are shown\nseparately here so the documentation does not suggest that they can be combined\nin one declaration.\n\n### Validating a method argument\n\n```typescript\nconst search = yield* query('search', {\n methodSchema: SearchInputSchema,\n method: (input) => ({ term: input.term }),\n loader: async ({ params }) => fetchResults(params),\n});\n```\n\n`methodSchema` validates the argument received by `call`, `mutate` or `method`;\nthe method then receives the schema output value.\n\n### Validating reactive params\n\n```typescript\nconst products = yield* query('products', {\n paramsSchema: FiltersSchema,\n params: () => ({ page: 1, term: searchTerm() }),\n loader: async ({ params }) => fetchProducts(params),\n});\n```\n\n`paramsSchema` validates the value produced by `params` or a reactive source.\n\n### Validating the loader result\n\nThis is the one that matters most: the loader is where **data you don't control**\nenters the app.\n\n```typescript\nconst products = yield* query('products', {\n loaderSchema: ProductsSchema,\n params: () => ({ page: 1 }),\n loader: async ({ params }) => fetchProducts(params),\n});\n```\n\n`loaderSchema` covers more than the initial fetch — it validates loader results,\n**stream values**, and **local writes** through `set`, `update` and `patch`. So a\nvalue that enters the resource later, by any path, is checked the same way.\n\nIf the schema transforms (a `.trim()`, a coercion, a rename), the resource\npublishes the **output** type — the rest of your code sees the transformed shape,\nnot the raw one.\n\n::: warning `response<User>()` is a claim, not a check\nWith `CraftHttpClient`, the type parameter only *asserts* what the endpoint\nreturns. Nothing verifies it at runtime:\n\n```typescript\nloader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/products',\n success: response<Product[]>(), // trusted, never verified\n }));\n}\n```\n\nTwo ways to make it real. Add `loaderSchema` to the query, which validates\nwhatever the loader returns:\n\n```typescript\nyield* query('products', {\n loaderSchema: ProductsSchema,\n loader: /* the CraftHttpClient call above */,\n});\n```\n\nOr decode at the request itself — `response(...)` takes any\n`{ decode(input: unknown) }`, which every schema library provides:\n\n```typescript\nsuccess: response({ decode: (input) => ProductsSchema.parse(input) }),\n```\n\nUse `loaderSchema` when you want the failure to surface as a craft exception\nunder `exceptions().parse.loader` and to obey the validation policy; use `decode`\nwhen the decoding belongs to the endpoint's own contract.\n:::\n\n## State\n\nState schemas are declared beside `$self` and validate initial values, writes,\ninsertions and values produced by `craftComputed`:\n\n```typescript\nconst user = yield* state('user', {\n $self: { id: 123, name: 'Alice' },\n schema: UserSchema,\n});\n```\n\nThe input type constrains `$self`; the exposed reader uses the schema output\ntype. Invalid derived values keep the last valid value when the policy rejects\nthem.\n\n### Derived state\n\nA schema also validates every new value produced by a `craftComputed` while\nkeeping the dependency reactive:\n\n```typescript\nconst price = yield* state('price', 10);\nconst quantity = yield* state('quantity', 2, ({ set }) => ({ set }));\n\nconst total = yield* state('total', {\n $self: craftComputed('totalSelf', function* () {\n return (yield* price()) * (yield* quantity());\n }),\n schema: NonNegativeNumberSchema,\n});\n\nconsole.log(yield* total()); // 20\nyield* quantity.set(3);\nconsole.log(yield* total()); // 30\n```\n\nWhen a derived value fails validation, the configured policy decides whether\nthe last valid value is retained or the new value is accepted.\n\n## Policy and exceptions\n\nThe default policy rejects invalid values in development and accepts them in\nproduction. It can be replaced globally or locally:\n\n```typescript\nprovideCraftSchemaValidationPolicy(({ exception }) => {\n monitoring.captureException(exception);\n return { action: isDevMode() ? 'reject' : 'accept' };\n});\n```\n\n```typescript\nquery('products', {\n loaderSchema: ProductsSchema,\n schemaValidationPolicy: () => ({ action: 'reject' }),\n // ...\n});\n```\n\nRejected parses produce a `SCHEMA_VALIDATION_ERROR` with `scope: 'parse'`.\nResource exceptions expose the stage through `exceptions().parse.method`,\n`exceptions().parse.params` and `exceptions().parse.loader`; states expose\n`exceptions().parse.state`.\n\nAll four primitives expose `hasSchema()`, which is `true` when at least one\nschema is configured.\n\n## Effect Schema\n\nEffect Schema works, but not by handing the schema over directly. An\n`effect/Schema` is **not** itself a Standard Schema — you convert it once with\n`Schema.toStandardSchemaV1`, and the result goes anywhere a schema goes:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst Person = Schema.Struct({\n name: Schema.String,\n age: Schema.Number,\n});\n\nconst people = yield* query('people', {\n loaderSchema: Schema.toStandardSchemaV1(Schema.Array(Person)),\n loader: async () => fetchPeople(),\n});\n```\n\nNothing in `@craft-ts/core` knows about Effect, and `@craft-ts/effect` ships no\nadapter for this: the whole interop is the Standard Schema spec, which both\nsides already implement. You do not need `@craft-ts/effect` installed to\nvalidate with Effect Schema.\n\nFailures behave like any other schema failure. Effect's issues become a\n`SCHEMA_VALIDATION_ERROR` on the parse channel — they are never thrown, and\nnever surface as an Effect `Cause`:\n\n```typescript\ncraftUse(person.exceptions()).parse.state?._tag; // 'SCHEMA_VALIDATION_ERROR'\n```\n\n### The one thing to watch: async decoding\n\n`paramsSchema`, `methodSchema` and the local writes (`set`, `update`, `patch`)\nare **synchronous** stages. They throw if a schema returns a `Promise`:\n\n> The query:people params schema returned a Promise where a synchronous result\n> is required.\n\nA plain Effect schema decodes synchronously, so it is fine at every stage. But\na schema with an asynchronous transformation is only usable in `loaderSchema`,\nwhich is the one stage that awaits. If you need to validate against something\nasync — a uniqueness check, a remote lookup — do it in the loader as an Effect\nand let its typed error flow through `runEffect`, rather than hiding it in a\nschema.\n\n### Decoded output, not encoded input\n\nCraft publishes the schema **output**. When the Effect schema decodes into a\ndifferent type than it accepts, it is the decoded type the rest of your code\nsees:\n\n```typescript\n// `Schema.Date` accepts a Date and REJECTS a string. The one that decodes is\n// `Schema.DateFromString` — encoded: string, decoded: Date.\nconst createdAt = yield* state('createdAt', {\n $self: rawFromServer, // string\n schema: Schema.toStandardSchemaV1(Schema.DateFromString),\n});\n\ncraftUse(createdAt()); // Date\n```\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Persistence](/guide/state/persistence) — validating restored values\n- [Exceptions as values](/guide/concepts/exceptions)\n"
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"path": "/guide/state/select",
|
|
359
|
+
"title": "Selecting a sub-state",
|
|
360
|
+
"body": "# Selecting a sub-state\n\n`insertSelect` targets a nested part of a state and attaches insertions **to that\npart**, so the logic lives next to the data it operates on rather than at the top\nof a deeply nested object.\n\n**Use it when** a state is a tree and a method only concerns one branch: a cell\nin a grid, a row in a table, one section of a settings object.\n**Not when** the whole state is the subject — a plain insertion is simpler.\n\nOne API covers both shapes: the parent can be an **object** or an **array**, and\nyou don't switch helpers based on which.\n\n::: info `state` only\nThis insertion works with the `state` primitive.\n:::\n\n```typescript\nimport {\n insertSelect,\n insertStatePipe,\n insertStoragePersister,\n state,\n} from '@craft-ts/core';\n```\n\n## The common case — selecting an object property\n\n```typescript\nconst board = yield* state(\n 'board',\n {\n cell: {\n color: 'white',\n paintCount: 0,\n },\n },\n insertSelect('cell', ({ update, state }) => ({\n paint: () =>\n update((cell) => ({\n ...cell,\n color: 'black',\n paintCount: cell.paintCount + 1,\n })),\n paintCountStr: function* () {\n return `Painted ${(yield* state()).paintCount} times`;\n },\n })),\n);\n\nyield* board.selectCell().paint();\nyield* board.selectCell().paintCountStr(); // \"Painted 1 times\"\n```\n\n## Selecting into an array\n\n```typescript\nconst cells = yield* state(\n 'cells',\n [{ color: 'white', paintCount: 0 }],\n insertSelect('cell', ({ update }) => ({\n paint: () =>\n update((cell) => ({\n ...cell,\n color: 'black',\n paintCount: cell.paintCount + 1,\n })),\n })),\n);\n\nconst cell = cells.selectCell(0);\nif (cell) yield* cell.paint();\nconsole.log(cells.selectCell(0)?.paintCount); // 1\n```\n\n## Yielding dependencies\n\n```typescript\ninsertSelect('cell', function* ({ patch }) {\n const color = yield* ColorService();\n return {\n paint: () =>\n patch(() => ({\n color,\n })),\n };\n});\n```\n\nThe dependencies are tracked at the primitive level.\n\n## Pitfalls\n\n**Only object properties can be selected.** On an object state, targeting a\nproperty that is not itself an object — a `string`, `number` or `boolean` — is\nnot supported yet, and currently breaks type inference rather than failing\ncleanly. An improvement is planned.\n\n**A select takes a single nested insertion**, like any primitive. Use\n`craftPipe` for more than one nested insertion (below).\n\n::: tip Nested typing needs no anchor\nUse `craftPipe` when composing nested `insertSelect` levels because each level\nhas its own explicit context. The historical `insertNoopTypingAnchor` workaround\nis not needed here — it remains necessary for the [form-tree helpers](/guide/forms/nested).\n:::\n\n## Attaching several insertions\n\nLike the primitives, `insertSelect` accepts a **single** nested insertion. To\nattach several, re-pass the selected context through\n[craftPipe](/guide/concepts/insertions):\n\n```ts\nstate(\n 'board',\n { grid: createInitialGrid() },\n insertSelect('grid', (gridContext) =>\n craftPipe(\n gridContext,\n ({ state, update }) => ({\n addRow: () => update((grid) => [...grid, createNextRow(grid)]),\n }),\n insertSelect('row', ({ update }) => ({\n // ...\n })),\n ),\n ),\n);\n```\n\n`insertSelect` also composes as a **member** of a pipe:\n\n```ts\nstate('cells', initialCells, insertStatePipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'cells',\n })),\n insertSelect('cell', ({ update }) => ({\n paint: () => update((cell) => ({ ...cell, painted: true })),\n })),\n ));\n```\n\n::: details Working examples — pixel art\nTwo demos built almost entirely on nested selects:\n\n- [Pixel Art (1D grid)](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/pixel-art/pixel-art.ts)\n- [Pixel Art Matrix (2D grid)](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts)\n\n:::\n\n## See Also\n\n- [Insertions](/guide/concepts/insertions) — composing several on one primitive\n- [Local state](/guide/state/local-state)\n- [Collections](/guide/state/collections) — for entity lists specifically\n- [Architecture rules](/guide/testing/architecture) — `assertInsertSelectUnique` when two selects share a key on one host\n"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"path": "/guide/state/server-state",
|
|
364
|
+
"title": "query",
|
|
365
|
+
"body": "# query\n\n`query` fetches data and owns its whole lifecycle — loading, resolved,\nexception — re-running itself when its inputs change.\n\n**Use it when** you display data that lives on a server.\n**Not when** you write to the server ([`mutation`](/guide/state/mutations)) or\nrun a one-off async action that isn't a fetch\n([`asyncProcess`](/guide/state/async-process)).\n\n::: warning One source of truth\nDon't copy a query's result into a `state`. The query _is_ the state.\nDon't reload it from a `craftEffect` either — put the inputs in `params` so\nthe loader re-runs when they change.\n:::\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: () => ({ userId: currentUserId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params.userId}`,\n success: response<User>(),\n }));\n },\n });\n```\n\n`params` is reactive: when what it returns changes, the loader runs again. The\nresult carries the full async state:\n\n```typescript\nuserQuery.value(); // User | undefined — never throws\nuserQuery.isLoading(); // boolean\nuserQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\nuserQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## Reading only settled data\n\nUse `settledValue` when a template or derived computation requires a real\nvalue. It suspends to the nearest `pendingBlock` while the first value is\nunavailable, propagates query exceptions to a `catchBlock`, and keeps the\nprevious value during a reload.\n\n```typescript\nconst userName = craftComputed('userName', function* () {\n return (yield* settled(userQuery)).name;\n});\n\nconst user = craftUse(userQuery.settledValue());\n```\n\nInsertion contexts keep the existing fallback behaviour of `state()`. Use\n`settledState()` when `yield*` (or `craftUse`) should return a non-nullable\nvalue and suspend until the current resource is available.\n\n## Triggering it yourself\n\nWhen the trigger is a user action rather than a reactive input, use `method`\ninstead of `params`:\n\n```typescript\nconst { searchQuery } =\n yield *\n query('searchQuery', {\n method: (term: string) => term,\n loader: function* ({ params: term }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/search?q=${term}`,\n success: response<Array<{ id: string; title: string }>>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * searchQuery.call('craft');\n```\n\nFrom an ordinary UI callback, the imperative form remains valid:\n`click: () => searchQuery.call(term)`. Do not put either form in a\n`craftEffect` dependency graph; use reactive `params` for data loading.\n\n## Adding derived values\n\nSame insertion mechanism as any primitive:\n\n```typescript\nconst { todosQuery } =\n yield *\n query(\n 'todosQuery',\n {\n params: () => ({ completed: showCompleted() }),\n loader: async ({ params }) =>\n (await fetch(`/api/todos?completed=${params.completed}`)).json(),\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* todosQuery.count();\n```\n\nAn insertion can also be a `function*` when it needs to yield services.\n\n## Enriching every item in a list\n\nWhen a query returns an array, `insertQuerySelect` attaches an insertion to each\nselected item. The selector keeps the item type, so derived values can use its\nproperties without casting:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core';\n\ntype User = {\n id: string;\n firstName: string;\n lastName: string;\n role: 'admin' | 'member';\n};\n\nconst { usersQuery } =\n yield *\n query(\n 'usersQuery',\n {\n params: () => ({ teamId: currentTeamId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/teams/${params.teamId}/users`,\n success: response<User[]>(),\n }));\n },\n },\n insertQuerySelect('user', ({ state }) => ({\n displayName: craftComputed(function* () {\n const user = yield* state();\n return `${user.firstName} ${user.lastName}`;\n }),\n roleLabel: craftComputed(function* () {\n return (yield* state()).role === 'admin' ? 'Administrator' : 'Member';\n }),\n })),\n );\n\n// `selectUser` targets one item in the returned array.\nconst firstUser = usersQuery.selectUser(0);\nyield* firstUser?.displayName(); // 'Ada Lovelace'\nyield* firstUser?.roleLabel(); // 'Administrator'\n```\n\nThe same pattern supports selecting a nested object property with\n`insertQuerySelect`, while preserving the selected property's type.\n\n## Avoiding the flicker when inputs change\n\n**This is already the default.** When `params` change, the previous value stays\nvisible until the new one resolves, so a paginated list never blanks out\nmid-navigation.\n\nYou only touch the option to turn it **off**:\n\n```typescript\nquery('postsQuery', {\n params: () => ({ page: currentPage() }),\n preservePreviousValue: () => false, // clear the value while loading\n loader: async ({ params }) =>\n (await fetch(`/api/posts?page=${params.page}`)).json(),\n});\n```\n\n::: tip Not consulted for parallel queries\nWith an `identifier`, each key keeps its own resource, so there is no \"previous\nvalue\" to preserve — the option is ignored on that path.\n:::\n\n## Reacting to a mutation\n\nRather than reloading by hand after a write, declare the link:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst userQuery = yield* query(\n 'userQuery',\n {\n params: () => ({ userId: currentUserId() }),\n loader: /* … */,\n },\n insertQueryPipe(\n insertReactOnMutation(updateUserMutation, {\n // apply the change immediately, before the server answers\n optimisticPatch: {\n name: ({ mutationParams }) => mutationParams.name,\n email: ({ mutationParams }) => mutationParams.email,\n },\n // and go get the truth back if the mutation failed\n reload: { onMutationException: true },\n }),\n insertStoragePersister(craftUnique({\n storeName: 'demo-app',\n key: 'user-query',\n })),\n ),\n);\n```\n\nFull options on [Reacting to mutations](/guide/state/react-on-mutation).\n\n## Exceptions\n\n`exceptions()` is split by **origin** and typed from the codes you declared —\n`params` for what your `method` rejected before any request, `loader` for what\nthe request produced:\n\n```typescript\nimport { craftException, query } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'forbidden'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * userQuery.call('ab');\nuserQuery.hasException(); // true\nuserQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * userQuery.call('forbidden');\nuserQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs — you\ndon't send a request you already know will fail.\n\n## Pitfalls\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the query is loading or in exception.\n\n**`params` must be cheap and pure.** It runs inside a reactive computation; side\neffects belong in the loader.\n\n::: details Advanced — parallel queries by identifier\n`identifier` keeps one resource per key, so several runs coexist instead of\nreplacing each other:\n\n```typescript\nconst userId = signal<number | undefined>(undefined);\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: userId,\n identifier: (id) => id,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n });\n\nuserId.set(1);\nuserId.set(2);\n\nuserQuery.select('1').value(); // user 1\nuserQuery.select('2').value(); // user 2\n```\n\n:::\n\n::: details Advanced — typed HTTP exceptions\nLoader exceptions are matched declaratively: each matcher yields predicates on\nthe response and returns a `craftException` when it recognises the failure.\n\n```typescript\nloader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeatureForDependencies',\n });\n },\n function* ({ body, header }) {\n const payload = yield* body<{\n errors?: Array<{ field: 'password' }>;\n }>();\n\n if (!payload.errors?.some((error) => error.field === 'password')) return;\n if (!(yield* header('x-error-kind', 'validation'))) return;\n\n return craftException({\n _tag: 'VALIDATION_HEADER_ERROR',\n scope: 'UsersFeatureForDependencies',\n });\n },\n ],\n }));\n}\n```\n\nWorking source:\n[exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts).\n:::\n\n::: details Advanced — yielding dependencies from `params`\n`params` can be a generator, and so can an insertion:\n\n```typescript\nconst { userQuery } =\n yield *\n query(\n 'userQuery',\n {\n providers: [provideUserService(), provideUserApiService()],\n params: function* () {\n return yield* UserService.userId();\n },\n loader: function* ({ params: userId }) {\n return yield* UserApiService.get(userId);\n },\n },\n function* () {\n const queryTools = yield* QueryTools();\n return { queryKey: `${queryTools.prefix()}:details` };\n },\n );\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryMethodRuntimeContext()`, and the query\nvalue itself is published to `providePrimitiveResourceRuntimeObserver`. Both\nexpose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and\nother advanced patterns can seed or replace a result without going through the\ninsertion callback. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Mutations](/guide/state/mutations) — the write side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
"path": "/guide/state/url-state",
|
|
369
|
+
"title": "queryParams",
|
|
370
|
+
"body": "# queryParams\n\n`queryParams` is a state whose home is the URL's query string. Reading and\nwriting look like any other state; the address bar follows, and so does the back\nbutton.\n\n**Use it when** the value should survive a refresh and be shareable by copying\nthe link: filters, pagination, a selected tab.\n**Not when** the value is ephemeral or private — that's\n[`state`](/guide/state/local-state).\n\n::: tip No synchronisation code\nThere is no effect to write and no `ActivatedRoute` subscription. If you find\nyourself syncing a `state` with the URL, you want this primitive instead.\n:::\n\n## The common case\n\n```typescript\nimport { queryParams } from '@craft-ts/core';\n\nconst numberCodec = {\n decode: (value: string) => parseInt(value, 10),\n encode: (value: number) => String(value),\n};\nconst booleanCodec = {\n decode: (value: string) => value === 'true',\n encode: (value: boolean) => String(value),\n};\n\nconst pagination = yield* queryParams(\n 'pagination',\n {\n state: {\n page: { fallbackValue: 1, codec: numberCodec },\n showArchived: { fallbackValue: false, codec: booleanCodec },\n },\n },\n ({ set, update, patch, reset }) => ({ set, update, patch, reset }),\n);\n\npagination(); // { page: 1, showArchived: false }\npagination.page(); // 1\n\npagination.patch({ showArchived: true }); // navigates to ?showArchived=true\npagination.set({ page: 4, showArchived: false });\npagination.update((current) => ({ ...current, page: current.page + 1 }));\npagination.reset();\n```\n\n`?page=3&showArchived=true` becomes `{ page: 3, showArchived: true }` on load.\n\n## Codecs are mandatory\n\nA URL only holds strings, so every parameter declares how it converts both ways.\nThe decoded type is your application type; the encoded one is what appears in the\naddress bar.\n\n`fallbackValue` is what you get when the parameter is absent — which is why the\nstate type is never `undefined`.\n\nCodecs stay synchronous because they run inside the reactive URL computation.\n`@craft-ts/core` deliberately doesn't depend on a validation library: supply a\nsmall `{ decode, encode }` pair directly, or adapt one from the library you\nalready use.\n\n```typescript\n// arrays\ntags: {\n fallbackValue: [],\n codec: {\n decode: (value) => value.split(',').filter(Boolean),\n encode: (value) => value.join(','),\n },\n},\n\n// plain strings\nq: { fallbackValue: '', codec: { decode: String, encode: String } },\n```\n\nThe same pattern covers dates, enums and JSON-encoded objects.\n\n## Custom methods\n\n```typescript\nyield* queryParams(\n 'pagination',\n {\n state: { page: { fallbackValue: 1, codec: numberCodec } },\n },\n ({ state, patch }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n previousPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page - 1 });\n },\n setPageSize: function* (pageSize: number) {\n return yield* patch({ pageSize, page: 1 });\n },\n }),\n);\n```\n\n## Feeding a query\n\nThe point of URL state is usually to drive a fetch. Read it from the query's\n`params`:\n\n```typescript\nyield* query('tasksQuery', {\n params: () => ({ page: pagination.page() }),\n loader: /* … */,\n});\n```\n\nOne direction of data flow: click → URL → loader → view.\n\n## Decode failures\n\nA `decode` that throws keeps the fallback value rather than corrupting your\nstate, and surfaces the failure:\n\n```typescript\nif (mode.hasException()) {\n mode.exceptions().list;\n mode.exceptions().parse.mode?.code; // 'QueryParamDecodeError'\n mode.exceptions().parse.mode?.payload;\n}\n```\n\nAn encode failure raises `QueryParamEncodeError` before router navigation starts.\n\n## Pitfalls\n\n**Every parameter needs a `codec`** — there is no implicit string passthrough.\n\n**Methods bound to a source with `on$` are not exposed** on the result, same as\nevery primitive.\n\n::: details Advanced — declaring query params on the route\nQuery parameters can live in the route rather than in a component, so they belong\nto the URL definition itself:\n\n```typescript\nexport const { demoRoutes, injectDemoQueryParamsQueryParams } = craftRoutes(\n 'demo',\n [\n {\n path: 'query-params',\n ...loadCraftComponent(({ withRetry }) =>\n withRetry(import('./qp-list-with-pagination')).then(\n ({ default: component }) => component,\n ),\n ),\n queryParams: function* () {\n const pagination = yield* queryParams(\n 'pagination',\n {\n state: {\n page: { fallbackValue: 1, codec: numberCodec },\n pageSize: { fallbackValue: 4, codec: numberCodec },\n },\n },\n ({ patch, state }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n previousPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page - 1 });\n },\n updatePageSize: function* (pageSize: number) {\n return yield* patch({ pageSize, page: 1 });\n },\n }),\n );\n return pagination;\n },\n },\n ],\n);\n```\n\n\n\nWorking source:\n[exception-query-params.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exception-query-params.ts).\n:::\n\n::: details Advanced — yielding dependencies\nThe insertion can be a generator, so a rule can come from a service:\n\n```typescript\nyield* queryParams(\n 'pagination',\n { state: { page: { fallbackValue: 1, codec: numberCodec } } },\n function* ({ patch, state }) {\n const maxPage = yield* PaginationRules.maxPage();\n return {\n nextPage: function* () {\n const current = yield* state();\n if (current.page >= maxPage()) return;\n return yield* patch(({ page }) => ({ page: page + 1 }));\n },\n };\n },\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryParamsMethodRuntimeContext()`, and the\nURL state itself is published to `providePrimitiveResourceRuntimeObserver`.\nBoth expose `get`, `set`, `update`, and `patch` for wrappers, WebMCP tools,\nand other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Local state](/guide/state/local-state) — for non-URL state\n- [query](/guide/state/server-state) — consuming URL state from a loader\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"path": "/guide/testing/architecture",
|
|
374
|
+
"title": "Architecture rules",
|
|
375
|
+
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| --- | --- | --- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the five\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI or folder ownership.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `ValidateCascadeRoutesFile`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| ------------------------------ | ---------------------------------------------------- |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is five graph-wide checks. Import them all, then\neither call each one or `assertDeclarativeArchitecture` for the five together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| --- | --- |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the five baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the five checks above and joins their messages. Pass `{ allow }` through\nto `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`,\n`ValidateCascadeRoutesFile`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive *has* an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(\n graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind,\n ).toBe('unique');\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | ------------------------------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
"path": "/guide/testing/architecture/computed-purity",
|
|
379
|
+
"title": "Pure `craftComputed` derivations",
|
|
380
|
+
"body": "# Pure `craftComputed` derivations\n\n`assertCraftComputedPure` requires a `craftComputed` to read dependencies and\nreturn a value. It may not call a method or write to a source:\n\n\n\n## The safe shape\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\n## What it prevents\n\nThis looks convenient but makes a derivation an imperative workflow:\n\n```typescript\nconst count = craftComputed('count', function* () {\n yield* audit.log('count recomputed');\n yield* tasks.set(normalizeTasks(yield* tasks()));\n return (yield* tasks()).length;\n});\n```\n\nNow reading `count` can write state, invoke a method, or trigger another\ncomputation. Re-computation order becomes observable, and a harmless template\nread can cause a loop.\n\n## The `set` can be hidden in a local function\n\nMoving the write into a helper does not make it a derivation. This is still\ninvalid:\n\n```typescript\nconst tasks = yield* state(\n 'tasks',\n initialTasks,\n ({ set }) => ({ set }),\n);\n\nconst remaining = craftComputed('remaining', function* () {\n // The write is not on the next line, but the helper belongs to this computed.\n const normalizeAndStore = function* (value: Task[]) {\n yield* tasks.set(normalizeTasks(value));\n };\n\n const current = yield* tasks();\n yield* normalizeAndStore(current);\n return current.filter((task) => !task.done).length;\n});\n```\n\nThe graph still records the relationship:\n\n```text\ncraftComputed:remaining ──writes──▶ state:tasks\n```\n\nSo `assertCraftComputedPure` rejects it even though the computed body only calls\n`normalizeAndStore` at the apparent call site. The failure points back to the\ncomputed and the write target, rather than relying on a reviewer to notice a\n`set` several lines down.\n\nThe same applies to an indirect method call:\n\n```typescript\nconst refresh = craftMethod('refresh', function* () {\n yield* tasks.set(initialTasks);\n});\n\nconst count = craftComputed('count', function* () {\n const runRefresh = () => refresh();\n yield* runRefresh();\n return (yield* tasks()).length;\n});\n```\n\nThe rule sees the `calls` edge from `count` to `refresh`. This is why the check\nbelongs on the graph in addition to a local ESLint rule: it protects the\ninvariant even when the side effect is hidden behind a binding.\n\nThe fix is to keep the computed read-only and move the write to an explicit\nmethod or event:\n\n```typescript\nconst normalize = craftMethod('normalize', function* () {\n yield* tasks.set(normalizeTasks(yield* tasks()));\n});\n\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\n## Where the side effect belongs\n\n- derive a value with `craftComputed`;\n- react to an event with `on$`;\n- update a primitive from a user action with a method;\n- run external work with `craftEffect` or an explicit resource primitive.\n\nSeparating these roles makes the dependency graph explainable and tests\ndeterministic.\n\n## See also\n\n- [The mental model](/guide/concepts/mental-model)\n- [`craftComputed`](/guide/reactivity/craft-computed)\n"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"path": "/guide/testing/architecture/craft-effect-imperative-sync",
|
|
384
|
+
"title": "Keep `craftEffect` out of imperative synchronisation",
|
|
385
|
+
"body": "# Keep `craftEffect` out of imperative synchronisation\n\n`assertCraftEffectNoImperativeSync` prevents a `craftEffect` from writing a\nstate/source or triggering another query, mutation or async process:\n\n\n\n## The syntax is valid — the placement is not\n\nThe following calls are valid Craft generator syntax. `set`, `call` and\n`mutate` return yieldable operations, so a generator consumes them with\n`yield*`:\n\n```typescript\nfunction* submit() {\n yield* searchResults.set(yield* rawResults());\n yield* usersQuery.call(yield* searchTerm());\n yield* saveMutation.mutate(yield* draft());\n}\n```\n\nThe problem is putting the same code in a `craftEffect`. This is exactly the\ncase rejected by `assertCraftEffectNoImperativeSync`:\n\n```typescript\ncraftEffect('sync', function* () {\n yield* searchResults.set(yield* rawResults());\n yield* usersQuery.call(yield* searchTerm());\n yield* saveMutation.mutate(yield* draft());\n});\n```\n\nThe rule is therefore not saying that `yield* searchResults.set(...)` is\ninvalid TypeScript or invalid Craft syntax. It is saying that a reactive\neffect must not imperatively write or trigger another Craft primitive.\n\n## What it prevents\n\nThis effect creates three hidden edges in the Craft graph:\n\n```typescript\ncraftEffect('sync', function* () {\n yield* searchResults.set(yield* rawResults());\n yield* usersQuery.call(yield* searchTerm());\n yield* saveMutation.mutate(yield* draft());\n});\n```\n\nThe graph is effectively:\n\n```text\nsync effect ──writes──▶ searchResults\n ├─calls────▶ usersQuery\n └─calls────▶ saveMutation\n```\n\nWhenever one of the values read by the effect changes, the effect can write\nstate, start a query and start a mutation again. The direction of data flow is\nhidden in a callback, which can create feedback loops, duplicate requests or a\nmutation that runs merely because a signal was read.\n\n## Use the primitive that owns the relationship instead\n\nIf the query depends on `searchTerm`, make that dependency explicit with\n`params`:\n\n```typescript\nconst usersQuery = yield* query('usersQuery', {\n params: searchTerm,\n loader: ({ params }) => searchUsers(params),\n});\n```\n\nIf all three operations belong to one explicit user action, use `craftMethod`\ninstead of `craftEffect`:\n\n```typescript\nconst sync = craftMethod('sync', function* () {\n yield* searchResults.set(yield* rawResults());\n yield* usersQuery.call(yield* searchTerm());\n yield* saveMutation.mutate(yield* draft());\n});\n```\n\nCall `sync` from the submit or click handler. It then runs once per explicit\ninvocation, rather than once per reactive recomputation.\n\nFor a mutation-to-query relationship, use an insertion such as\n`insertReactOnMutation`. For a named external event, use `on$`. Use a computed\nvalue when `searchResults` is only a transformation of `rawResults`, instead\nof storing a second value and synchronising it.\n\nLogging, focus and other effects that do not push into Craft primitives remain\nvalid. The rule protects synchronization, not all side effects.\n\n## See also\n\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [From event to source](/guide/reactivity/from-event-to-source)\n"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"path": "/guide/testing/architecture/craft-effect-network",
|
|
389
|
+
"title": "Keep `craftEffect` off the network",
|
|
390
|
+
"body": "# Keep `craftEffect` off the network\n\n`assertCraftEffectNoNetwork` prevents a reactive `craftEffect` from calling HTTP\nor a mutation:\n\n\n\n## What it prevents\n\nThis is a query disguised as an effect:\n\n```typescript\ncraftEffect('poll', function* () {\n yield* CraftHttpClient.get(loadUsers);\n});\n```\n\nIt has no standard query loading state, cache identity, cancellation contract or\nread-side exception flow. It can also run again whenever an unrelated reactive\ndependency changes.\n\nThis is a mutation disguised as an effect:\n\n```typescript\ncraftEffect('save', function* () {\n yield* saveMutation.mutate(payload);\n});\n```\n\nThe write has no explicit user action or mutation relationship in its declaration.\n\n## The intended alternatives\n\n- use `query` / `queryEffect` for reads;\n- use `mutation` / `mutationEffect` for writes;\n- use `asyncProcess` / `asyncProcessEffect` for explicit commands;\n- keep `craftEffect` for reactive side effects such as logging, focus or\n integration with a non-Craft sink.\n\nThe rule protects the semantic boundary, not the use of Effects in general.\n\n## See also\n\n- [craftEffect](/guide/reactivity/craft-effect)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"path": "/guide/testing/architecture/declarative-baseline",
|
|
394
|
+
"title": "Declarative architecture baseline",
|
|
395
|
+
"body": "# Declarative architecture baseline\n\n`assertDeclarativeArchitecture` is the first architecture test to add to a\nCraft app. It is not a style check and it does not test the DOM. It reads the\nstatic Craft graph and verifies five relationships that are easy to lose during\na refactor:\n\n\n\nThe same test protects five different failure modes:\n\n| Rule | If it is missing, this can happen |\n| --- | --- |\n| `assertCraftUnique` | two persisted resources restore from the same storage slot |\n| `assertHttpEndpointUnique` | two services own `GET users` and evolve it differently |\n| `assertCraftComputedPure` | reading a derived value writes state or starts work |\n| `assertNoDependencyCycles` | service construction loops through `A → B → A` |\n| `assertMutationHasReactOn` | a successful write leaves the visible list stale |\n\nThe following examples show the actual code shape that each rule rejects.\n\n## 1. Two persisted resources share one identity\n\nTwo feature files can both look reasonable in isolation:\n\n```typescript\n// features/users/user-list.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\n```typescript\n// features/users/user-detail.ts\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user' }),\n);\n```\n\nThey do not create a list cache and a detail cache. They create one storage\nidentity with two call sites. Restoring the detail can overwrite the value that\nthe list expects, and the bug only appears after a reload or cache restore.\n\n`assertCraftUnique` fails with both file locations. The fix is to give the\nresources distinct identities:\n\n```typescript\ncraftUnique({ storeName: 'shop', key: 'user-list' });\ncraftUnique({ storeName: 'shop', key: 'user-detail' });\n```\n\nSee [Unique identities](./unique-identities) and\n[Persisted identities](./persisted-identities).\n\n## 2. Two services call the same HTTP endpoint\n\nThis duplication is also invisible to TypeScript:\n\n```typescript\n// users-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<User[]>(),\n}));\n```\n\n```typescript\n// admin-api.ts\nconst users = yield* CraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<AdminUser[]>(),\n}));\n```\n\nBoth are `GET users`. One feature can add pagination or change the response\nshape while the other keeps the old assumption. The two call sites are now\ncompeting owners of one transport contract.\n\n`assertHttpEndpointUnique` forces one owner. The other service must depend on\nthat owner and derive its own view:\n\n```typescript\nconst users = yield* UsersApi();\nconst admins = craftComputed('admins', function* () {\n return (yield* users.list()).filter((user) => user.role === 'admin');\n});\n```\n\nSee [HTTP endpoint ownership](./http-endpoint-ownership).\n\n## 3. A computed value performs work while being read\n\nThe purpose of `craftComputed` is to derive a value:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nThis version changes the meaning of a read:\n\n```typescript\nconst remaining = craftComputed('remaining', function* () {\n yield* audit.log('recomputed');\n yield* tasks.set(normalizeTasks(yield* tasks()));\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nNow a template read can write state, call a method or trigger another graph\nbranch. Depending on recomputation order, this can produce a loop, duplicate\nwork or state that changes merely because it was displayed.\n\n`assertCraftComputedPure` rejects both direct writes and calls through another\nmethod binding. Put the work in a method, `on$`, `query` or `mutation`, and let\nthe computed only read.\n\nSee [Computed purity](./computed-purity).\n\n## 4. Two services depend on each other\n\nThe graph for this pair is enough to fail the suite:\n\n```text\nUserList ──depends-on──▶ UserMutation\nUserMutation ──depends-on──▶ UserList\n```\n\nIn source, the cycle usually comes from two factories each yielding the other:\n\n```typescript\n// features/users/user-list.ts\nfunction* userListFactory() {\n const mutation = yield* UserMutation();\n return { mutation };\n}\n\n// features/users/user-mutation.ts\nfunction* userMutationFactory() {\n const list = yield* UserList();\n return { list };\n}\n```\n\nThe surrounding `craftService(...)` declarations are omitted here; the\nimportant part is the two dependency edges created by the `yield*` calls.\n\nThe application may not fail until the route first constructs the services. Then\nit can recurse forever or expose a partially constructed value.\n\n`assertNoDependencyCycles` identifies the path. Break it by extracting a small\nshared contract, passing an input, or moving a derived value into the consumer.\nTwo features depending on a common `Auth` service are not a cycle:\n\n```text\nUserList ──▶ Auth ◀── Checkout\n```\n\nSee [Dependency cycles](./dependency-cycles).\n\n## 5. A mutation succeeds but the list never refreshes\n\nThe orphan mutation is the most user-visible failure:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n method: (input: NewTask) => input,\n loader: saveTask,\n});\n\nconst tasks = yield* query('tasks', {\n params: () => filters(),\n loader: loadTasks,\n});\n```\n\nThe server has the new task, but the query has no declared relationship with the\nmutation. The user clicks “Create”, gets a success response, and still sees the\nold list until a hard reload.\n\nDeclare the relationship on the query:\n\n```typescript\nconst tasks = yield* query(\n 'tasks',\n {\n params: () => filters(),\n loader: loadTasks,\n },\n insertReactOnMutation(createTask, {\n reload: { onMutationSuccess: true },\n }),\n);\n```\n\nThe graph records a `triggers` edge from `createTask` to `tasks`. The exact\npolicy can be a reload, an optimistic patch or another supported insertion; the\nimportant part is that it is declared where the query is defined.\n\nSee [Mutation reactions](./mutation-reactions).\n\n## What the aggregate test does — and does not do\n\nThe aggregate test is now understandable as a compact CI gate:\n\n```typescript\nit('protects the baseline graph invariants', () => {\n assertDeclarativeArchitecture(graph.graph);\n});\n```\n\nIt does **not** cover every architecture policy. Add focused assertions for:\n\n- route DI and error-screen proofs: [`assertRouteDiProofs`](./route-di-proofs);\n- route/page file boundaries: [`assertRouteComponentsInSeparateFiles`](./route-component-files);\n- folder ownership: [`assertPathBoundaries`](./path-boundaries);\n- Effect loader boundaries: [`assertPrimitiveLoaderRequirements`](./primitive-loader-requirements);\n- interactive control names: [`assertInteractiveElementNamed`](./interactive-element-names);\n- `craftEffect` network and imperative-sync constraints: [Effect rules](./craft-effect-network).\n\nKeep the aggregate assertion for the common baseline, and keep focused rules for\npolicies whose failure message should explain a product or team boundary.\n\n## Explicit exceptions\n\nSome mutations really are fire-and-forget: logout, telemetry or an export with\nno cached query. Name those exceptions instead of weakening the whole rule:\n\n```typescript\nassertDeclarativeArchitecture(graph.graph, {\n allow: ['logout', 'sendTelemetry'],\n});\n```\n\nAn `allow` entry is a documented decision. It should be narrow enough that a new\norphan mutation cannot hide inside it.\n\n## See also\n\n- [Architecture rules](/guide/testing/architecture)\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n"
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"path": "/guide/testing/architecture/dependency-cycles",
|
|
399
|
+
"title": "No dependency cycles",
|
|
400
|
+
"body": "# No dependency cycles\n\n`assertNoDependencyCycles` checks directed `depends-on` edges between services,\ncomponents and computeds:\n\n\n\n## What it prevents\n\nThe obvious cycle is two services that construct each other:\n\n```text\nLeft → Right → Left\n```\n\nIn source, it often appears as a harmless pair of `yield*` calls. At runtime it\ncan fail as a recursive construction, an incomplete service or a provider that\nonly breaks when a route is first visited.\n\nThe rule also catches a self-dependency and cycles involving computed values.\n\n## Shared dependencies are not cycles\n\nThis is valid:\n\n```text\nAdminPage → Auth\nCheckout → Auth\n```\n\nBoth branches depend on a shared kernel; there is no path back from `Auth` to\neither branch. `provides`, `contains`, `loads` and `renders` are structural\nedges and are not treated as dependency cycles.\n\n## How to break a real cycle\n\nUsually one side should depend on a smaller contract:\n\n- extract a read-only service from the two large services;\n- move shared policy into a third service;\n- pass a value as an input instead of resolving the owning service;\n- move a derived value into the consumer instead of publishing it back.\n\nDo not silence a cycle by adding an `allow` list: this assertion has no such\nescape hatch because a cycle changes construction semantics.\n\n## See also\n\n- [Service scopes](/guide/app/service-scopes)\n- [Composing services](/learn/04-compose)\n"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"path": "/guide/testing/architecture/exclusive-links",
|
|
404
|
+
"title": "Exclusive branch links",
|
|
405
|
+
"body": "# Exclusive branch links\n\n`noExclusiveLink(a, b)` checks that two branches do not depend on each other\nthrough a private leak. Shared kernel nodes are allowed:\n\n\n\n## What it prevents\n\nSuppose two features are intended to be independent:\n\n```text\nadmin → checkout-private-service → checkout\n```\n\nThe application has now created a hidden integration. A future checkout rewrite\nmust preserve an admin-only dependency, and removing the link can break a route\nthat was never listed as a consumer.\n\nThe same problem appears between feature services:\n\n```text\nUserList → UserMutation → UserList\n```\n\nor when one route reaches directly into another feature's private data service.\n\n## Shared kernels are not leaks\n\nThis is allowed:\n\n```text\nadmin → Auth\ncheckout → Auth\n```\n\nThe helper stops membership at other `provides` sites, so a common auth service,\nHTTP boundary or browser boundary is treated as shared infrastructure rather\nthan as a feature-to-feature link.\n\n## Use it with routes or services\n\nThe arguments are graph nodes, so the same invariant can protect route branches,\nfeature services or any two catalog lookups.\n\n## See also\n\n- [Path boundaries](./path-boundaries)\n- [Architecture graph lookups](/guide/testing/architecture#looking-up-nodes)\n"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"path": "/guide/testing/architecture/http-endpoint-ownership",
|
|
409
|
+
"title": "Unique HTTP endpoint ownership",
|
|
410
|
+
"body": "# Unique HTTP endpoint ownership\n\n`assertHttpEndpointUnique` treats an HTTP endpoint as the pair of its method and\nURL. It fails when two graph sites call the same pair:\n\n\n\n## What it prevents\n\nTwo services can independently implement this:\n\n```typescript\nCraftHttpClient.get(({ response }) => ({\n url: 'users',\n success: response<User[]>(),\n}));\n```\n\nThe application still compiles, but there are now two owners for `GET users`.\nOne may add pagination, the other may keep an old response shape. A bug fix in\none call site does not reach the other.\n\nThe rule forces a single boundary service to own `GET users`. Other services\ndepend on that service and can derive feature-specific views without creating a\nsecond transport contract.\n\n## What counts as distinct\n\nThese are separate endpoints and are allowed:\n\n```text\nGET users\nPOST users\nGET orders\n```\n\nThe rule is intentionally narrower than “one URL in the whole app”: a read and\na write are different contracts.\n\n## Why this is graph-wide\n\nESLint can flag a local HTTP style mistake. It cannot see that a second feature\nhas claimed an endpoint already owned elsewhere. The graph can inspect every\ncall site in one assertion.\n\n## See also\n\n- [Browser boundaries](/guide/testing/browser-boundaries)\n- [Architecture rules](/guide/testing/architecture)\n"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"path": "/guide/testing/architecture/insert-select-keys",
|
|
414
|
+
"title": "Unique `insertSelect` keys per host",
|
|
415
|
+
"body": "# Unique `insertSelect` keys per host\n\n`assertInsertSelectUnique` requires each `insertSelect` key to appear once on a\ngiven host primitive:\n\n\n\n## What it prevents\n\nAn insertion key names a selected slice on its host:\n\n```typescript\nquery(\n 'users',\n config,\n insertSelect('cell', selectUserCell),\n insertSelect('cell', selectAnotherCell), // collision\n);\n```\n\nBoth insertions claim `cell`. Depending on insertion order, one can replace the\nother, or consumers can read a type that no longer matches the runtime branch.\nThe failure is especially hard to spot when the two insertions live in separate\nfeature helpers.\n\n## The same key on another host is valid\n\n```typescript\nstate('users', initialUsers, insertSelect('cell', selectUserCell));\nstate('orders', initialOrders, insertSelect('cell', selectOrderCell));\n```\n\nThe key is local to a host. The rule does not impose a useless app-wide naming\nscheme.\n\n## What to do after a failure\n\nUse a key that describes the selected contract (`'summary'`, `'pagination'`,\n`'selectedUser'`) or merge the two selection behaviours into one insertion when\nthey are really one public slice.\n\n## See also\n\n- [Selecting](/guide/state/select)\n- [Typed insertion pipes](/guide/concepts/insertion-pipes)\n"
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"path": "/guide/testing/architecture/interactive-element-names",
|
|
419
|
+
"title": "Named interactive elements",
|
|
420
|
+
"body": "# Named interactive elements\n\n`assertInteractiveElementNamed` requires a unique literal Craft name on every\ninteractive element:\n\n\n\n## What it prevents\n\nThis control has no stable graph or test identity:\n\n```typescript\nbutton({ *click() { yield* increment(); } }, '+');\n```\n\nThis one is named, but two components using the same name make the app-wide\n`data-craft-name` lookup ambiguous:\n\n```typescript\nbutton('save', { *click() { yield* save(); } }, 'Save');\n```\n\nThe ambiguity matters to type-level template tests, browser tests and the live\npage tooling used by coding agents. A selector based on “the second Save button”\nis not a stable contract.\n\n## What is checked\n\nThe rule covers `button`, links, form controls and nodes with `click`, `input`,\n`change` or `submit`. Hidden inputs are excluded. The first argument must be a\nliteral string and the resulting name must be unique in the application.\n\n```typescript\nbutton('save-profile', { type: 'button', *click() { yield* save(); } }, 'Save');\n```\n\nUse a feature-qualified name when the control is likely to recur. ESLint catches\nlocal omissions; the architecture rule catches duplicates across components.\n\n## See also\n\n- [Components](/guide/components/)\n- [Live page MCP](/guide/ai/dev-page)\n- [Testing components](/guide/testing/components)\n"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
"path": "/guide/testing/architecture/mutation-reactions",
|
|
424
|
+
"title": "Mutations must have a read-side reaction",
|
|
425
|
+
"body": "# Mutations must have a read-side reaction\n\n`assertMutationHasReactOn` requires every mutation to have an\n`insertReactOnMutation` edge to a query, unless the mutation is explicitly\nallowed:\n\n\n\n## What it prevents\n\nThe stale-list bug is easy to write:\n\n```typescript\nconst createTask = yield* mutation('createTask', {\n method: (input: NewTask) => input,\n loader: saveTask,\n});\n\n// The list query exists, but nothing says it reacts to createTask.\n```\n\nThe write succeeds and the database contains the new task, while the list on\nscreen remains unchanged until a full reload. The graph sees that the mutation\nhas no `triggers` edge and fails CI.\n\n## The declared relationship\n\nPut the insertion on the query:\n\n```typescript\nconst tasks = yield* query(\n 'tasks',\n { params: filters, loader: loadTasks },\n insertReactOnMutation(createTask, {\n reload: { onMutationSuccess: true },\n }),\n);\n```\n\nThe same rule covers nested `insertQueryPipe` composition. It does not require\nevery mutation to reload every query — only that the write has an explicit\nread-side policy somewhere.\n\n## Legitimate fire-and-forget writes\n\nLogout, telemetry and an export may intentionally have no query to refresh:\n\n```typescript\nassertMutationHasReactOn(graph.graph, {\n allow: ['logout', 'sendTelemetry', 'exportUsers'],\n});\n```\n\nKeep the allowlist named and small so a newly orphaned mutation cannot hide in a\ngeneric `allow: ['*']` convention.\n\n## See also\n\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Write server data](/learn/06-mutate-data)\n"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"path": "/guide/testing/architecture/path-boundaries",
|
|
429
|
+
"title": "Folder path boundaries",
|
|
430
|
+
"body": "# Folder path boundaries\n\n`assertPathBoundaries` applies architectural allowlists and denylists to paths\ninside one application. It checks graph dependencies, and can optionally check\ncalls:\n\n\n\n## What it prevents\n\nWithout a folder rule, these imports are easy to introduce:\n\n```text\nfeatures/users → features/cart\nui/widget → data/users-api\n```\n\nThe first couples sibling features. The second lets presentation code bypass\nthe domain or browser-boundary service. Both can work today and make tomorrow's\nmove or replacement expensive.\n\nThe `:feature` capture permits a feature to depend on its own folder while\nforbidding a sibling. A denylist such as `features/**` would accidentally forbid\nself-dependencies too.\n\n## Why this is not just ESLint\n\nNx `depConstraints` protect project-to-project imports. This rule protects\nfolders within an app, including routes, services and components that belong to\nthe same Nx project. Structural edges such as `loads` and `renders` are ignored;\nthe rule is about ownership and dependency flow.\n\n## See also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx)\n- [Writing your own rules](/guide/testing/architecture#writing-your-own-rules)\n"
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
"path": "/guide/testing/architecture/persisted-identities",
|
|
434
|
+
"title": "Persisted primitives need a unique identity",
|
|
435
|
+
"body": "# Persisted primitives need a unique identity\n\n`assertPersistedPrimitiveHasUnique` checks the inverse of the general identity\nrule: every primitive using storage persistence must receive a `craftUnique`\nidentity.\n\n\n\n## What it prevents\n\nThis is not safe enough for a persisted query:\n\n```typescript\ninsertStoragePersister({\n storeName: 'shop',\n key: 'user-list',\n});\n```\n\nThe persister may work, but the graph cannot prove that the identity is static\nor that another primitive does not use the same slot. A refactor can silently\nmake two resources share storage.\n\nMake the boundary explicit:\n\n```typescript\ninsertStoragePersister(\n craftUnique({ storeName: 'shop', key: 'user-list' }),\n);\n```\n\nThis rule and [`assertCraftUnique`](./unique-identities) are complementary:\n\n```text\nassertPersistedPrimitiveHasUnique → every persisted primitive has an identity\nassertCraftUnique → every identity is unique and verifiable\n```\n\n## When persistence is deliberately absent\n\nAn in-memory `query` or `state` has no persister and needs no identity. Do not\nwrap every primitive in `craftUnique`; add it where storage, persistence or\nanother identity-indexed integration needs one.\n\n## See also\n\n- [Persistence](/guide/state/persistence)\n- [Unique identities](./unique-identities)\n"
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
"path": "/guide/testing/architecture/primitive-loader-requirements",
|
|
439
|
+
"title": "Primitive loader requirements",
|
|
440
|
+
"body": "# Primitive loader requirements\n\n`assertPrimitiveLoaderRequirements` is the configurable form of the server-state\nrule. It says what a primitive loader must reach, without hard-coding one\ntransport library into the graph:\n\n\n\n## What it prevents\n\nAn Effect-aware query can look perfectly typed while accidentally becoming a\nlocal computation:\n\n```typescript\nconst users = yield* queryEffect('users', {\n params: () => filter(),\n loader: () => Effect.succeed(localFixture),\n});\n```\n\nThat is a valid Effect program, but it does not prove that the feature reaches a\nrepository, gateway or other server-state boundary. The rule makes the policy\nexplicit and catches the accidental local fallback.\n\n## Requirements are OR-ed\n\nA project can accept several boundary styles:\n\n```typescript\nrequirements: [\n { label: 'Effect service', matches: isEffectService },\n { label: 'domain gateway', matches: isDomainGateway },\n { label: 'server function', matches: isServerFunctionFamily },\n]\n```\n\nFor `queryEffect` and `mutationEffect`, the graph projects the Effect `R`\nchannel onto the matching Effect service nodes. A loader that calls a domain\nfunction requiring `UserRepository` therefore satisfies the rule even though\nthe loader itself does not directly yield the service.\n\n## Use `allow` as a documented exception\n\n```typescript\nassertPrimitiveLoaderRequirements(graph.graph, {\n primitives: ['queryEffect'],\n requirements: [{ label: 'Effect service', matches: isEffectService }],\n allow: ['currentUserQuery'], // app-level DI bridge; intentionally local\n});\n```\n\nThe name should explain the exception. A broad allowlist defeats the point of a\nloader-boundary rule.\n\n## See also\n\n- [Server-state loader rule](./server-state-loader)\n- [Effect integration](/guide/advanced/effect)\n"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"path": "/guide/testing/architecture/route-component-files",
|
|
444
|
+
"title": "Route page components each live in their own file",
|
|
445
|
+
"body": "# Route page components each live in their own file\n\n`assertRouteComponentsInSeparateFiles` requires every component attached to a\nroute through `component`, `loadComponent` or a lazy `import()` to live outside\nthe route-definition file. It also rejects two different routed page components\nsharing one common component file:\n\n\n\n## What it prevents\n\nThis keeps the page boundary explicit:\n\n```typescript\n// pages.routes.ts\nexport const pagesRoutes = craftRoutes('pages', [\n {\n path: 'orders',\n loadComponent: () => import('./orders-page'),\n },\n]);\n```\n\n```typescript\n// orders-page.ts\nexport const OrdersPage = craftComponent(/* ... */);\n```\n\nPutting both declarations in `pages.routes.ts` makes route files grow into\nfeature modules. Putting `OrdersPage` and `CustomersPage` in one\n`pages.components.ts` file creates the same problem at the lazy boundary: the\nchunk is no longer organized around one page entry point.\n\nThe rule covers both eager route targets and lazy targets. A route collection\nmay contain several routes, but each page component declaration still has its\nown source file. Child components rendered by a page are not route targets and\nare not restricted by this rule. Reusing the same component declaration from\nmultiple routes is not treated as two page components.\n\n## The intended shape\n\nKeep the route tree responsible for navigation and loading:\n\n```typescript\n// account.routes.ts\nexport const accountRoutes = craftRoutes('account', [\n {\n path: 'profile',\n loadComponent: ({ withRetry }) =>\n withRetry(import('./profile-page')).then((module) => module.ProfilePage),\n },\n]);\n```\n\nKeep the page implementation in its own file, with its own component\ndependencies and tests. `loadChildren` remains the right choice when a whole\nroute collection should be lazy, while the collection's page components still\nfollow this file boundary.\n\n## Failure message\n\nThe assertion reports the route, component and offending source file. Move each\npage component to its own sibling file, then keep the route's import literal so\nthe router and bundler can discover the lazy boundary.\n\n## See also\n\n- [Routing setup](/guide/routing/setup)\n- [Scaling routes](/guide/routing/scaling)\n- [Route DI proofs](./route-di-proofs)\n"
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"path": "/guide/testing/architecture/route-di-proofs",
|
|
449
|
+
"title": "Route DI proofs and exception coverage",
|
|
450
|
+
"body": "# Route DI proofs and exception coverage\n\n`assertRouteDiProofs` keeps type-level routing guarantees armed at runtime in\nCI. It checks that routed components, lazy route collections, pending screens\nand error screens have a live mapper connected to a `CanRun` proof:\n\n\n\n## What it prevents\n\n`RouteCheckedDI` is intentionally an unused type alias:\n\n```typescript\ntype Check = RouteCheckedDI<ComponentDeps, 'CraftRouter', never, 'tasks'>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nIf somebody comments out `CanRunCheck`, TypeScript still compiles. The proof no\nlonger runs, and a later missing provider can become a runtime navigation\nfailure. `assertRouteDiProofs` spots the unarmed mapper by inspecting the graph.\n\nThe same applies to a child route file: a parent cascade proof cannot cover a\nlazy `loadChildren` collection that was added later.\n\n## It also covers error surfaces\n\nThe rule requires checks for:\n\n- routed components;\n- pending components;\n- route and global error components;\n- route-load error components;\n- `assertExhaustiveRouteExceptions` on route collections.\n\nWithout the error-screen checks, the happy route can be type-safe while the\nfirst missing provider renders an unverified fallback.\n\n## The expected pairing\n\n```typescript\ntype CheckTasks = RouteCheckedDI<\n ComponentDepsOf<typeof Tasks>,\n 'CraftRouter',\n never,\n 'tasks'\n>;\ntype CanRunTasks = CanRun<CheckTasks>;\n\nassertExhaustiveRouteExceptions(appRoutes);\n```\n\nTypeScript checks whether the provider is available; this rule checks that the\napplication actually invoked that judgement.\n\n## See also\n\n- [Routing setup](/guide/routing/setup)\n- [Route providers](/guide/routing/route-providers)\n- [Route exception handling](/guide/routing/exception-handling)\n"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
"path": "/guide/testing/architecture/server-state-loader",
|
|
454
|
+
"title": "Queries and mutations must reach server state",
|
|
455
|
+
"body": "# Queries and mutations must reach server state\n\n`assertQueryMutationHasServerState` verifies that `query` and `mutation`\nloaders reach an approved server-state boundary, such as `CraftHttpClient` or a\nclient-exposed server-function family:\n\n\n\n## What it prevents\n\nThis resource is named like server state but only returns local data:\n\n```typescript\nconst users = yield* query('users', {\n params: () => filter(),\n loader: () => cachedUsers,\n});\n```\n\nThat can be intentional in a demo, but in a production feature it hides a\nmissing API call or accidentally replaces a remote source with a fixture. The\nquery's loading and cache semantics then give a false impression that the server\nhas been consulted.\n\n## Effect applications choose their boundary\n\nAn Effect app can use a custom requirement instead of making every loader call\n`CraftHttpClient`:\n\n```typescript\nassertPrimitiveLoaderRequirements(graph.graph, {\n primitives: ['queryEffect', 'mutationEffect'],\n requirements: [\n {\n label: 'an Effect service',\n matches: ({ target }) =>\n target.kind === 'service' && target.details?.runtime === 'effect',\n },\n ],\n});\n```\n\nFor local fixtures, use a narrow named `allow` entry and explain why it is not\nserver state. Do not disable the rule for every primitive.\n\n## See also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Primitive loader requirements](./primitive-loader-requirements)\n- [Effect integration](/guide/advanced/effect)\n"
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"path": "/guide/testing/architecture/unique-identities",
|
|
459
|
+
"title": "Unique `craftUnique` identities",
|
|
460
|
+
"body": "# Unique `craftUnique` identities\n\n`assertCraftUnique` checks that every `craftUnique(...)` identity is a static,\nsingle-use identity in the application graph.\n\n\n\n## What it prevents\n\nPersistence is keyed by identity, not by the variable name around it:\n\n```typescript\ninsertStoragePersister(craftUnique({\n storeName: 'shop',\n key: 'user-list',\n}));\n```\n\nIf the list and detail features both use `{ storeName: 'shop', key: 'user' }`,\nthey do not get two caches. They get one storage slot, and whichever feature\nwrites last changes what the other feature restores.\n\nThe rule also rejects a computed identity:\n\n```typescript\nconst key = featureName();\ncraftUnique({ storeName: 'shop', key }); // not statically verifiable\n```\n\nStatic literals let the catalog show every call site and let CI prove that a\nrename did not silently merge two persisted resources. The canonical JSON is\norder-independent, so swapping `key` and `storeName` does not evade the check.\n\n## Different stores are different identities\n\nThe same key is valid in two stores:\n\n```typescript\ncraftUnique({ storeName: 'shop', key: 'user' });\ncraftUnique({ storeName: 'admin', key: 'user' });\n```\n\nThe complete identity is the pair, not the key alone.\n\n## See also\n\n- [Persisted primitive identities](./persisted-identities)\n- [Persistence](/guide/state/persistence)\n"
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"path": "/guide/testing/browser-boundaries",
|
|
464
|
+
"title": "Browser boundaries",
|
|
465
|
+
"body": "# Browser boundaries\n\nA browser boundary is the line between your logic and the outside world:\n`localStorage`, `navigator`, `location`, the network. Craft keeps direct access\nout of your services while still making each of those dependencies **explicit in\nthe graph** — so a test can replace exactly them, and nothing else.\n\n**Use them when** a service touches the platform.\n**Then test with `boundaryOnly`**: the whole application graph stays real and\nonly the boundaries are mocked, which is what makes a passing test mean\nsomething.\n\nBrowser boundaries keep direct browser access out of your `craftService` implementations while still making those dependencies explicit in the service graph.\n\nEvery boundary on this page is backed by a global crafted service marked with `browserBoundary: true`.\n\n::: warning\nSome APIs are not documented here yet.\n:::\n\n## Import\n\nThe main DSL exports are:\n\n```typescript\nimport {\n BrowserCrypto,\n BrowserDocument,\n BrowserHistory,\n BrowserLocation,\n BrowserNavigator,\n BrowserPerformance,\n BrowserWindow,\n Console,\n Cookies,\n LocalStorage,\n SessionStorage,\n} from '@craft-ts/core';\n```\n\nWhen you need to derive methods for later reuse, each boundary also exposes the usual generated helpers:\n\n```typescript\nimport { ConsoleService, CONSOLE_SERVICE_META_DATA } from '@craft-ts/core';\n```\n\nThe same pattern exists for the other boundaries:\n\n- `LocalStorageService`\n- `SessionStorageService`\n- `CookiesService`\n- `BrowserLocationService`\n- `BrowserHistoryService`\n- `BrowserNavigatorService`\n- `BrowserPerformanceService`\n- `BrowserCryptoService`\n- `BrowserDocumentService`\n- `BrowserWindowService`\n\n## Motivation\n\nDirect browser access inside a service hides dependencies inside business logic and makes tracking harder.\n\nBrowser boundaries solve that in two complementary ways:\n\n- use `yield* X.method(...)` when the browser interaction should happen directly inside the generator\n- use `XService(...)` when you want to derive bound browser helpers and reuse them inside returned callbacks\n\n## Mental Model\n\nThere are two valid ways to use a browser boundary.\n\n### Direct DSL\n\nUse the DSL when the browser interaction belongs to the generator itself.\n\n\n\n\n### Derived Service Helper\n\nUse `XService(...)` when the browser method needs to stay callable later from a returned method.\n\n\n\n\nThat second form is what preserves derivability while still tracking the browser dependency explicitly.\n\n## Core Examples\n\n### Console\n\n```typescript\nyield * Console.log('my service run');\nyield * Console.error('unexpected failure', error);\n```\n\n### Local Storage\n\n```typescript\nyield * LocalStorage.setItem('token', token);\n\nconst persistedToken = yield * LocalStorage.getItem('token');\nconst entryCount = yield * LocalStorage.length();\n```\n\n### Session Storage\n\n```typescript\nyield * SessionStorage.setItem('active-tab', 'settings');\n\nconst tab = yield * SessionStorage.getItem('active-tab');\n```\n\n### Cookies\n\n```typescript\nyield *\n Cookies.set('session', sessionId, {\n path: '/',\n sameSite: 'strict',\n });\n\nconst session = yield * Cookies.get('session');\nconst hasSession = yield * Cookies.has('session');\n```\n\n### Location\n\n```typescript\nconst href = yield * BrowserLocation.href();\nconst pathname = yield * BrowserLocation.pathname();\n\nyield * BrowserLocation.reload();\n```\n\n### History\n\n```typescript\nyield * BrowserHistory.replaceState({ step: 2 }, '', '/checkout?step=2');\n\nconst state = yield * BrowserHistory.state();\n```\n\n### Document\n\n```typescript\nyield * BrowserDocument.setTitle('Checkout');\n\nconst title = yield * BrowserDocument.title();\n```\n\n### Window\n\n```typescript\nconst width = yield * BrowserWindow.innerWidth();\n\nyield * BrowserWindow.scrollTo(0, 0);\nyield * BrowserWindow.alert('Cache cleared! The page will reload.');\n\nconst confirmed =\n yield * BrowserWindow.confirm('Cache cleared! The page will reload.');\n\nif (confirmed) {\n yield * BrowserLocation.reload();\n}\n```\n\n### Performance And Crypto\n\n```typescript\nconst now = yield * BrowserPerformance.now();\nconst uuid = yield * BrowserCrypto.randomUUID();\n```\n\n## API Reference\n\nEvery service below is:\n\n- `providedIn: 'global'`\n- `browserBoundary: true`\n- exposed both as a DSL object and as generated service helpers\n\n### `Console` and `ConsoleService`\n\nMethods:\n\n- `debug`\n- `info`\n- `log`\n- `warn`\n- `error`\n- `trace`\n- `group`\n- `groupCollapsed`\n- `groupEnd`\n- `time`\n- `timeEnd`\n\nGenerated helpers:\n\n- `ConsoleService`\n- `ConsoleService`\n- `CONSOLE_SERVICE_META_DATA`\n\n### `LocalStorage` and `LocalStorageService`\n\nMethods:\n\n- `getItem`\n- `setItem`\n- `removeItem`\n- `clear`\n- `key`\n- `length`\n\n### `SessionStorage` and `SessionStorageService`\n\nMethods:\n\n- `getItem`\n- `setItem`\n- `removeItem`\n- `clear`\n- `key`\n- `length`\n\n### `Cookies` and `CookiesService`\n\nMethods:\n\n- `get`\n- `getAll`\n- `set`\n- `remove`\n- `has`\n\n### `BrowserLocation` and `BrowserLocationService`\n\nMethods:\n\n- `href`\n- `origin`\n- `protocol`\n- `host`\n- `hostname`\n- `port`\n- `pathname`\n- `search`\n- `hash`\n- `assign`\n- `replace`\n- `reload`\n\n### `BrowserHistory` and `BrowserHistoryService`\n\nMethods:\n\n- `length`\n- `state`\n- `back`\n- `forward`\n- `go`\n- `pushState`\n- `replaceState`\n\n### `BrowserNavigator` and `BrowserNavigatorService`\n\nMethods:\n\n- `userAgent`\n- `language`\n- `languages`\n- `onLine`\n- `cookieEnabled`\n- `sendBeacon`\n\n### `BrowserPerformance` and `BrowserPerformanceService`\n\nMethods:\n\n- `now`\n- `mark`\n- `measure`\n- `clearMarks`\n- `clearMeasures`\n\n### `BrowserCrypto` and `BrowserCryptoService`\n\nMethods:\n\n- `randomUUID`\n- `getRandomValues`\n- `digest`\n\n### `BrowserDocument` and `BrowserDocumentService`\n\nMethods:\n\n- `title`\n- `setTitle`\n- `lang`\n- `setLang`\n- `dir`\n- `setDir`\n- `visibilityState`\n- `hasFocus`\n\n### `BrowserWindow` and `BrowserWindowService`\n\nMethods:\n\n- `innerWidth`\n- `innerHeight`\n- `scrollX`\n- `scrollY`\n- `scrollTo`\n- `alert`\n- `confirm`\n\n## Related Adapter: `CraftHttpClient`\n\n`CraftHttpClient` is implemented, but it is not a browser boundary.\n\nUnlike `Console`, `LocalStorage`, or `BrowserLocation`, `CraftHttpClient` is a\ntyped service boundary. It belongs in the dependency graph rather than being\ntreated as a browser-global.\n\nIts contract is intentionally different:\n\n- it is not treated as `browserBoundary: true`\n- it requires `success: response<T>()` inside a declarative builder\n- it can declare ordered `exceptions: [function* (...) { ... }]` rules\n- it returns a promise of `Success | craftException({ _tag: 'HttpError' })`\n\nUsage looks like this:\n\n```typescript\nconst getUsers =\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n params: { page: 1 },\n success: response<User[]>(),\n }));\n\nconst createUser =\n yield *\n CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n payload,\n success: response<User>(),\n }));\n\nconst login =\n yield *\n CraftHttpClient.post(({ response }) => ({\n url: '/api/login',\n payload,\n success: response<{ token: string }>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({ _tag: 'PASSWORD_REQUIRED' });\n },\n ],\n }));\n\nconst users = await getUsers();\nconst createdUser = await createUser();\nconst loginResult = await login();\n```\n\n## Design Constraints\n\nThe browser boundaries stay intentionally narrow.\n\n- Reads are exposed as methods so the public API stays uniform with `yield*`.\n- Raw `window`, `document`, and DOM nodes are not exposed as public outputs.\n- `BrowserDocument` and `BrowserWindow` remain minimal rather than becoming generic escape hatches.\n\nThis keeps the API focused on explicit browser interactions instead of reintroducing broad direct access to host globals.\n\n## Relationship With `craftService`\n\nBrowser boundaries participate in the same dependency tracking model as any other crafted service.\n\n- [`craftService`](/guide/app/craft-service) is what you use to consume them and compose higher-level services.\n- Use a small `craftService` adapter for host dependencies that are not part of\n this built-in browser boundary set.\n\n## See Also\n\n- [`craftService`](/guide/app/craft-service)\n- [Architecture rules](/guide/testing/architecture) — assert HTTP only crosses a boundary\n"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"path": "/guide/testing/components",
|
|
469
|
+
"title": "Testing components",
|
|
470
|
+
"body": "# Testing components\n\nCraft components are tested in two independent halves: the **logic factory**\n(plain values, no DOM) and the **template** (real DOM, explicit locators). You\ncan test one without paying for the other.\n\n**Use the logic test** for what the factory computes and exposes.\n**Use the template test** for what actually renders, and for interaction.\n\nThe utilities live in a dedicated submodule:\n\n```ts\nimport {\n setupCraftComponentLogicTest,\n setupCraftComponentTemplateTest,\n setupCraftDirectiveLogicTest,\n setupCraftDirectiveTemplateTest,\n} from '@craft-ts/component/testing';\n```\n\nThey deliberately separate the factory from rendering. Each utility also\nexposes a `.byRegister(...)` form, which makes the services used by the tested\ncode explicit.\n\n## Component logic\n\nThe logic test executes only the factory and returns its context together with\nthe installed mocks:\n\n```ts\nconst { context, mocks, destroy } =\n await setupCraftComponentLogicTest.byRegister(FullDemoCraft, {\n register: {\n TodoStore: {\n todos: {\n status: () => 'resolved',\n value: () => [],\n },\n },\n },\n });\n\nexpect(context.store.todos.value()).toEqual([]);\nexpect(mocks.TodoStore).toBeDefined();\ndestroy();\n```\n\nFactory arguments can be provided through `args` when the component declares\ninputs:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(StatusComponent, {\n args: [statusInput],\n register: {},\n});\n```\n\n## Component template\n\nThe template test receives an already-built context. The component logic is not\nexecuted:\n\n```ts\nconst test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {\n context: { status: () => 'resolved' },\n register: {},\n});\n\nexpect(test.nativeElement.textContent).toContain('Loaded');\ntest.detectChanges();\ntest.updateContext({ status: () => 'error' });\nexpect(test.nativeElement.textContent).toContain('Error');\ntest.destroy();\n```\n\nThe result exposes `nativeElement`, `element`, `mocks`, `detectChanges`,\n`updateContext`, and `destroy`. Craft styles, child components, Craft\ndirectives, and reactivity are rendered by the normal renderer.\n\n### Explicit DOM locators\n\nTemplate tests also expose `locator(tag, criteria)`. The tag determines the\nDOM element type, while `class`, `data-*`, and `aria-*` criteria are matched\nagainst the rendered element:\n\n\n\n\nThe notation `tag('name', props, children)` is generic: `tag` means the HTML\nhelper for the element you want. There is no separate `tag` function. For a\nbutton, write the three arguments explicitly:\n\n```ts\nconst saveButton = button(\n 'save', // name: stable local name\n { class: 'save' }, // props: DOM properties and attributes\n 'Save', // children: rendered content\n);\n```\n\nThe same pattern works with every built-in helper:\n\n```ts\nimport { input } from '@craft-ts/component';\n\nconst searchInput = input('search', { 'aria-label': 'Search' }, []);\n```\n\nThe name is rendered as `data-craft-name=\"save\"` and can be used as a\ncomplementary named locator when a class is not sufficiently discriminating.\n\n### Locating branded content\n\nWhen an element directly renders a branded Craft value, use the brand name as\nthe `content` criterion. The locator does not inspect the rendered value, so\nthis also works for non-text values and remains independent of formatting:\n\n```typescript\nimport { craftSignal as signal } from '@craft-ts/core';\nimport { span, craftComponent } from '@craft-ts/component';\nimport { markYieldableValue, state } from '@craft-ts/core';\n\nconst Status = craftComponent(\n 'Status',\n {},\n function* () {\n const brandedStatus = yield* state('brandedStatus', 'ready');\n return { brandedStatus };\n },\n ({ brandedStatus }) => span(brandedStatus),\n);\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Status, {\n context: {\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nexpect(brandedStatusElement.textContent).toBe('ready');\ntest.destroy();\n```\n\n\n\nThis template has no `ifBlock`, `each`, or `defer`, so\n`brandedStatusElement` is an `HTMLSpanElement`, never `undefined`; optional\nchaining is not needed here.\n\nThe brand name is part of the template type. An unknown value such as\n`{ content: 'missing' }` is rejected by TypeScript. The return type is the\ninferred DOM type when the element is always rendered. Under `ifBlock`, `each`,\nor `defer`, it is `MaybeDefined<HTMLSpanElement>` (equivalent to\n`HTMLSpanElement | undefined`), so callers must handle the absent branch.\n\nUse static, discriminating markers for locators. A literal class or attribute\ndeclared in the template is a stable proof; a value produced by a binding is\nnot. Attributes declared through `attrs` are queried using their rendered\nattribute name:\n\n```ts\ninput({ attrs: { 'aria-label': 'Search' } });\ntest.locator('input', { 'aria-label': 'Search' });\n```\n\nThe locator searches the complete rendered subtree, including Craft child\ncomponents. A branch that is currently absent returns `undefined`; a runtime\nresult with more than one matching element throws an explicit cardinality\nerror. Call the locator again after `updateContext` and `detectChanges` when a\nconditional branch changes.\n\nWhen a class is not sufficiently discriminating, keep using the existing\nnamed locators (`tag('name', props, children)`) and query their\n`data-craft-name` marker. A future collection API will cover repeated targets;\nthe singular locator should remain reserved for one expected element.\n\nTo verify that a DOM property is connected to the correct context member, add a\ncontract assertion next to the template test:\n\n\n\n\nTypeScript performs this check. It fails if the branded `counter.disabled` read\nis no longer exposed by the rendered template. It does not replace the\nrendering test; it verifies the template contract without a DOM.\n\n## Context and service dependencies\n\nThe `context` is a factory value and is not a registry dependency. In this\nexample, `store` is provided directly to the template:\n\n```ts\nawait setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {\n context: { store: todoStoreMock },\n register: {},\n});\n```\n\nConversely, if `StatusComponent` or a child component uses a\n`FormatterService`, the template registry contains `FormatterService`, never\nthe child component:\n\n```ts\nregister: {\n FormatterService: formatterMock,\n}\n```\n\nThe `CraftComponentLogicDepsOf<Component>` and\n`CraftComponentTemplateDepsOf<Component>` projections keep these two graphs\nseparate. A template registry therefore accepts only services; child components\nare never entries in `register`.\n\n## Registry values and providers\n\nResolution follows the same rules as service tests:\n\n- an object is a mock and is available in `mocks`;\n- `'real'` keeps the real service;\n- `'notReached'` documents a branch removed by a parent mock;\n- `'provided'` requests the value provided by the parent injector;\n- a `provideX(...)` provider explicitly configures a service.\n\nProviders declared in `meta.providers` are available in the component scope.\nUpstream providers go in `providers`:\n\n```ts\nawait setupCraftComponentLogicTest.byRegister(Component, {\n providers: [provideApiService({ baseUrl: '/test' })],\n register: {\n ApiService: 'provided',\n },\n});\n```\n\n`appStart` decisions (`'run'` or `'ignore'`) are available in the options when\nthe tested graph contains a service with `appStart: true`.\n\n## Testing a directive\n\nDirective logic receives its `baseLogic` and arguments explicitly:\n\n```ts\nconst { context } = await setupCraftDirectiveLogicTest.byRegister(\n hasPermissionInput,\n {\n baseLogic,\n args: [userInput, permissionInput],\n register: {},\n },\n);\n```\n\nFor the template, provide `baseTemplate` and the final context:\n\n```ts\nconst test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {\n baseTemplate: (context) => p(context.message()),\n context: { when: () => true, message: () => 'ready' },\n register: {},\n});\n\ntest.updateContext({ when: () => false, message: () => 'hidden' });\ntest.destroy();\n```\n\nStructural directives follow the same path and can verify that rendering is\nreplaced with `[]`. Calling `destroy()` cleans up views, injectors, listeners,\nand acquired styles.\n\n## Type-level tests\n\nThe template's contract can also be checked **without rendering anything** —\nthat an element only appears under a condition, that a binding is really the one\nyou think, that a list item renders its label. That is its own page:\n**[Type-level tests](/guide/testing/type-level)**.\n\n## See Also\n\n- [Testing services](/guide/testing/services)\n- [Browser boundaries](/guide/testing/browser-boundaries)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Routing setup](/guide/routing/setup) — where `GenDeps_*` comes from\n"
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
"path": "/guide/testing/craft-graph-vs-nx",
|
|
474
|
+
"title": "Craft graph vs Nx",
|
|
475
|
+
"body": "# Craft graph vs Nx\n\nNx organises the **workspace**. The Craft graph judges the **shape of one\napp**. They are complementary — comparing `depConstraints` to\n`assertHttpEndpointUnique` is comparing a city plan to a wiring diagram.\n\n**Use Nx when** the constraint is about projects, TypeScript imports, or what\nCI should rerun.\n**Use Craft when** the constraint is about who may yield whom, who owns an\nHTTP endpoint, or whether a route proof stayed armed.\n**Not instead of** [architecture rules](/guide/testing/architecture) — this\npage is the why; that page is the how.\n\n::: tip They already run together\nThe demo suite is an Nx target: `npx nx architecture demo`. Craft does not\nreplace Nx; it adds a graph Nx cannot see.\n:::\n\n## Two graphs, two altitudes\n\n| | Nx | Craft |\n| --- | --- | --- |\n| Node | an app or a lib | a route, a service, a `GET users`, a `craftUnique` identity |\n| Edge | a TypeScript import (`static` / `dynamic` / `implicit`) | `depends-on`, `provides`, `calls`, `writes`, `checks`, … |\n| Question | who may import whom, and what should rerun? | who may yield whom, and who owns this endpoint? |\n| Enforcement | ESLint `@nx/enforce-module-boundaries` | Vitest on the graph (`assert*`, `noExclusiveLink`) |\n| Visualisation | `nx graph` — projects and tasks, `--affected` | `npx craft-graph --format html` — a route expanding into services and HTTP |\n\nNx orchestrates. Craft asserts.\n\n## What Nx cannot see\n\nNx's node is a project. Everything inside `apps/shop/src` is opaque: routes,\nproviders, `yield*`, HTTP, storage. That is not a bug — the project graph is\nbuilt to be cheap enough to drive CI. The holes appear as soon as you want to\njudge an **app**, not a workspace.\n\n| Nx sees | Nx misses | Craft counterpart |\n| --- | --- | --- |\n| `demo` imports `@craft-ts/core` | `yield* CheckoutApi` in the same app | `depends-on` |\n| Tags on a lib (`scope:admin`) | Two features that share one app | `assertPathBoundaries`, `noExclusiveLink` |\n| A circular import between libs | A service cycle `A → B → A` inside one tsconfig | `assertNoDependencyCycles` |\n| That `HttpClient` was imported | That `GET users` is called from two APIs | `assertHttpEndpointUnique` |\n| Nothing about storage keys | Two queries sharing a `craftUnique` identity | `assertCraftUnique` |\n| Nothing about unused type aliases | A commented-out `CanRun` that still compiles | `assertRouteDiProofs` |\n| Nothing about named buttons | Two interactive helpers sharing `data-craft-name` | `assertInteractiveElementNamed` |\n\nThree consequences follow.\n\n**An edge is an import, not DI.** `yield* CheckoutApi` does not cross a project\nboundary. Module-boundary ESLint never fires. The Craft graph records it as\n`depends-on`.\n\n**Isolation costs a library.** For `depConstraints` to protect a feature, that\nfeature must be its own project — barrel, tags, often a build. Craft states\nthe same intention on **folders** and on yield, without extracting forty libs.\n\n**ESLint judges a file.** `@nx/enforce-module-boundaries` sees one import.\n`assertHttpEndpointUnique` and `assertRouteDiProofs` see the whole graph,\nincluding lazy `loadChildren` collections a parent proof never covers.\n\nNx Enterprise Conformance can add workspace rules on the project graph and the\nfile tree. Recreating Craft's AST analysis (DI, yield, HTTP) there would be\nrewriting `@craft-ts/dev-tools`.\n\n## What Craft cannot see\n\nThe Craft graph is one TypeScript program (`analyzeDependencyGraph` takes one\ntsconfig). It does not become a build system.\n\n| Craft sees | Craft misses | Nx counterpart |\n| --- | --- | --- |\n| Who yields whom inside an app | Which projects CI should rerun | project graph + `nx affected` |\n| Folder lanes on `depends-on` | A deep import that bypasses a lib's `index.ts` | `enforce-module-boundaries` |\n| A duplicate `GET users` | Nest imported from a frontend project | `bannedExternalImports` |\n| A service cycle in `apps/shop` | `orders` ↔ `customers` as libs | circular project dependencies |\n| Craft TypeScript | Python, Nest, assets, configs | polyglot project graph, implicit deps |\n| A failing Vitest assertion | A generator that rewrites the file | Conformance fix generators |\n| Seconds of ts-morph analysis | Millisecond cache hits on unchanged libs | local / remote computation cache |\n\nThe Craft graph **asserts**. The Nx graph **executes**: what to build, test,\ncache, parallelise. Without Nx (or an equivalent), a green architecture suite\ndoes not scale in CI.\n\n`assertPathBoundaries` protects a modular monolith. It does not split the\n**task** graph. Changing one feature folder still invalidates the whole app\narchitecture target — analysis reloads the tsconfig, in seconds, not\nmilliseconds.\n\nCraft is a TypeScript analyser. Outside that dialect the graph is empty.\nNx tags Nest, React, Python, assets and config files.\n\n## The overlap\n\nSame intention, different edge.\n\n| Intention | Nx | Craft | Trap |\n| --- | --- | --- | --- |\n| This layer must not talk to that one | `sourceTag: type:ui` → `onlyDependOnLibsWithTags: type:util` | `assertPathBoundaries` — `src/app/ui/**` must not `depends-on` `src/app/data/**` | Nx requires libs. Craft runs inside one tsconfig, on yield, not on the import. |\n| No cycles | `orders` → `customers` → `orders` (project imports) | `assertNoDependencyCycles` on services / `craftComputed` | A service cycle inside `apps/shop` is green for Nx and red for Craft. |\n| See the graph | `nx graph --affected` (projects or tasks) | `craft-graph --format html` centred on a route | One shows who rebuilds. The other shows who injects. |\n\nThe `assertPathBoundaries` helper is the closest cousin of `depConstraints`.\nNx tags **projects** and forbids TypeScript imports. Craft tags **folders** on\nthe Craft graph and forbids `depends-on` (optionally `calls`) — including\ninside one app, where module-boundary ESLint does not run. Setup and examples:\n[Architecture rules](/guide/testing/architecture#assertpathboundaries).\n\n## How they complement\n\nDo not recode `depConstraints` as Vitest, and do not recode Craft's AST\nanalysis as an Nx Conformance rule. Each tool stays on its graph.\n\n| Keep Nx for | Keep Craft for |\n| --- | --- |\n| Monorepo layout, tags between libs, public API barrels | HTTP ownership, `craftUnique` identities |\n| `nx affected`, local / remote cache, task graph | Route DI proofs staying armed |\n| Banning an npm package by tag | Pure `craftComputed`, service-level cycles |\n| Generators, plugins, polyglot projects | `noExclusiveLink`, browser-boundary HTTP |\n\n::: warning Folder lanes are not affected CI\nExtracting Nx libraries gives you `nx affected`. It still does not prove a\nsingle service owns `GET users`. `assertPathBoundaries` keeps features apart\ninside one app. It still does not slice the task graph. Both layers stay\nnecessary.\n:::\n\nThe working reference is the demo app: an Nx `architecture` target that runs\nVitest on the Craft graph. Copy that layout from\n[Architecture rules](/guide/testing/architecture#setting-it-up).\n\n## See Also\n\n- [Architecture rules](/guide/testing/architecture) — helpers, catalog, Nx\n target\n- [ESLint rules](/guide/routing/eslint-rules) — local slips the graph cannot\n autofix\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"path": "/guide/testing/extensible-architecture-graph",
|
|
479
|
+
"title": "Extensible architecture graph",
|
|
480
|
+
"body": "# Extensible architecture graph\n\nThe Craft architecture graph is extensible by **TypeScript backend**, without\nmaking the graph engine import that backend. Use this when an application has\nserver-side concepts that the built-in Craft graph cannot express: services,\nlayers, message brokers, data classifications, external outputs, or another\ndependency-injection system.\n\nThe extension has two separate parts:\n\n- a type extension, which gives rules typed `kind` details;\n- a runtime collector, which reads the TypeScript program and contributes\n nodes, relations, diagnostics, and source proofs.\n\nImporting the types never activates a collector.\n\n## 1. Extend the vocabulary\n\nAdd entries to the node and relation registries with module augmentation. The\naugmentation must target the graph subpath:\n\n```typescript\nimport type {\n DependencyGraphCollector,\n DependencyGraphEdgeRegistry,\n DependencyGraphNodeRegistry,\n} from '@craft-ts/dev-tools/dependency-graph';\nimport { assertSensitiveOutputsProtected } from '@craft-ts/dev-tools/architecture-graph';\n\ndeclare module '@craft-ts/dev-tools/dependency-graph' {\n interface DependencyGraphNodeRegistry {\n 'repository-service': {\n runtime: 'repository';\n repositoryName: string;\n };\n }\n\n interface DependencyGraphEdgeRegistry {\n 'requires-repository': {\n operation: string;\n };\n }\n}\n```\n\nThe registry determines the types of the public graph API:\n\n```typescript\nconst repositories = graph.nodes('repository-service');\nconst name: string = repositories[0]!.details!.repositoryName;\n\nconst requirements = graph.edges('requires-repository');\nconst operation: string = requirements[0]!.details!.operation;\n```\n\nKeep a new node kind for a concept with its own semantics, renderer, or\narchitecture rules. Put incidental information in the typed `details` object\ninstead of creating a kind for every local symbol.\n\n## 2. Write a collector\n\nA collector receives the shared `ts-morph` project and the source files already\nselected by the graph's tsconfig. It returns a contribution; it does not call\narchitecture rules or mutate a renderer.\n\n```typescript\nconst repositoryCollector: DependencyGraphCollector = {\n name: 'repository-backend',\n\n collect({ rootDir, sourceFiles }) {\n const nodes = [];\n const edges = [];\n\n for (const sourceFile of sourceFiles) {\n // Inspect declarations and calls with ts-morph here.\n // Add a node or relation only when the syntax/type evidence is clear.\n void rootDir;\n void sourceFile;\n }\n\n return { nodes, edges };\n },\n};\n```\n\nEvery contributed node needs a stable `id`, a `kind`, and a human-readable\n`label`. Every relation must point to nodes in the contribution or to nodes\nalready in the graph. A conflicting identity is rejected during the merge.\n\n## 3. Attach source proofs\n\nA collector should explain why a fact exists. Add a `proof` to relations when\npossible:\n\n```typescript\nedges.push({\n from: handlerId,\n to: repositoryId,\n kind: 'requires-repository',\n evidence: 'ast',\n details: { operation: 'list' },\n proof: {\n filePath: sourceFile.getFilePath(),\n line: call.getStartLineNumber(),\n symbol: 'listUsers',\n pattern: 'repository.list()',\n },\n});\n```\n\nProofs are available through `graph.proofs(edge)` and are included in paths:\n\n```typescript\nconst paths = graph.pathsBetween(handlerId, repositoryId);\nfor (const path of paths) {\n console.log(path.nodes.map((node) => node.label));\n console.log(path.proofs);\n}\n```\n\nIf a dependency cannot be resolved statically, emit a diagnostic or an\nexplicit unknown relation. Do not publish an unresolved dependency as if it\nwere proven.\n\n## 4. Activate the collector explicitly\n\nRegister the collector in the application's graph loader:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n collectors: [repositoryCollector],\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data'],\n },\n});\n\nreturn createArchitectureGraph(graph, architectureCatalog);\n```\n\nThis keeps runtime analysis separate from declaration merging. A type import\ncannot accidentally enable an expensive backend analysis or its rules.\n\n## Effect backend\n\nThe repository currently includes an Effect adapter. It recognizes Effect\n`Context.Service` declarations and server-function requirements such as:\n\n```typescript\nexport class UserRepository extends Context.Service<\n UserRepository,\n UserRepositoryShape\n>()('demo/UserRepository') {}\n\nexport const listUsers = serverFunction('demo.users.list', inputSchema, {\n exposure: 'client',\n}).handler(({ input }) =>\n Effect.gen(function* () {\n const repository = yield* UserRepository;\n return yield* repository.list(input.filter);\n }),\n);\n```\n\nThe graph exposes `effect-service`, `effect-operation`, and `effect-layer`\nnodes, plus typed `requires-service`, `provided-by-layer`, and\n`composes-layer` relations with source proofs. `Layer.succeed`, `Layer.sync`,\n`Layer.effect`, `Layer.mergeAll`, and `Layer.provide` are followed only when\ntheir relevant symbols are statically visible. Dynamic composition is marked\npartial or unknown.\n\n## Sensitive data and output policies\n\nEffect Schema annotations can seed a conservative data-flow graph:\n\n```typescript\nconst Email = Schema.String.pipe(\n Schema.annotations({ sensitivity: 'personal-data' }),\n);\n```\n\nWhen an annotated schema is used as the output of a client-exposed server\nfunction, the graph emits a `data-classification` node, an `external-output`\nnode, and an `exposes-data` relation carrying the classification and proof.\nClassification is retained when propagation is uncertain; the analyser never\nassumes that an arbitrary transform made data safe.\n\nRepositories declare middleware capabilities beside the graph configuration:\n\n```typescript\nconst graph = analyzeDependencyGraph({\n tsConfigFilePath: 'apps/shop/tsconfig.graph.json',\n middlewareCapabilities: {\n 'shop.audit-sensitive-data': ['personal-data', 'secret'],\n },\n});\n```\n\nThen enforce the policy with:\n\n```typescript\nassertSensitiveOutputsProtected(graph.graph, {\n categories: ['personal-data', 'secret'],\n});\n```\n\nThe rule reports the output, classification, expected capability, and source\nproof when no attached server middleware provides the declared protection.\nUnknown protection remains blocking unless `allowUnknown: true` is chosen\nexplicitly.\n\n## Rendering and JSON compatibility\n\nThe JSON graph remains tolerant of kinds a renderer does not know. Generic\nrenderers display the kind and label as a fallback; they do not discard the\nnode. A backend-specific renderer or architecture rule can consume the typed\ndetails through declaration merging.\n\nWhen the vocabulary changes, regenerate the committed architecture catalog and\nrun the architecture suite:\n\n```shell\nnpx craft-graph \\\n --project apps/shop/tsconfig.graph.json \\\n --root . \\\n --out apps/shop/architecture/catalog \\\n --format json\n\nnpx nx architecture shop\n```\n\nSee [Architecture rules](/guide/testing/architecture) for the application\nloader, catalog generation, and baseline rules.\n"
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"path": "/guide/testing/services",
|
|
484
|
+
"title": "Testing services",
|
|
485
|
+
"body": "# Testing services\n\nMost test setups let you forget a dependency and find out at runtime. This one\ninverts it: you supply a **register** covering the service's whole dependency\ngraph, and the compiler refuses to run the test until every node is accounted\nfor.\n\n**Use it for** any `craftService`.\n**Use `boundaryOnly`** when the test should stay close to reality — it keeps the\nreal graph and only lets you replace\n[browser boundaries](/guide/testing/browser-boundaries).\n\n::: tip The register is as small as your yields\nA service that yielded one property needs one property mocked. Precise yields\nare what keep these tests short — see\n[Shaping the public API](/guide/app/expose-api).\n:::\n\n## Import\n\n```typescript\nimport {\n setupCraftServiceTestingByRegister,\n} from '@craft-ts/core';\n```\n\n## Introduction\n\n`setupCraftServiceTestingByRegister` is the exhaustive testing utility for the `craftService` graph.\n\nInstead of providing only the overrides you care about, you provide a full typed register where each service is marked as:\n\n- real\n- provided by its raw `provideX(...)`\n- mocked with a raw object\n- or pruned with `'notReached'`\n\nThis is useful when you want explicit control over every node in the service graph.\n\nFor tests that should stay close to reality, use `boundaryOnly`. It keeps the\napplication graph real by default and only lets you decide the services marked\nwith `browserBoundary: true`.\n\n## Register Workflow\n\nThe intended workflow is:\n\n1. start from the full dependency graph of the SUT\n2. fill each key with a real provider, `'real'`, a mock object, or `'notReached'`\n3. pass the register to `await setupCraftServiceTestingByRegister(...)`\n\n## Basic Example\n\n```typescript\nimport {\n craftService,\n craftUse,\n setupCraftServiceTestingByRegister,\n state,\n} from '@craft-ts/core';\nimport { vi } from 'vitest';\n\nconst { Counter } = craftService(\n { name: 'Counter', providedIn: 'global' },\n function* () {\n const counter = yield* state('counter', 10, ({ update }) => ({\n increment: () => update((value) => value + 1),\n }));\n return counter;\n },\n);\n\nconst { CounterConsumer, provideCounterConsumer } = craftService(\n { name: 'CounterConsumer', providedIn: 'toProvide' },\n function* () {\n const counter = yield* Counter();\n\n return {\n read: function* () {\n return yield* counter();\n },\n increment: function* () {\n return yield* counter.increment();\n },\n };\n },\n);\n\nconst { sut, mocks } = await setupCraftServiceTestingByRegister(\n CounterConsumer,\n {\n CounterConsumer: provideCounterConsumer(),\n Counter: {\n $self: vi.fn(() => 41),\n increment: vi.fn(),\n },\n },\n);\n\nexpect(craftUse(sut.read())).toBe(41);\ncraftUse(sut.increment());\nexpect(mocks.Counter.increment).toHaveBeenCalledTimes(1);\n```\n\n\n\n## Register Semantics\n\n### `'real'`\n\nUse `'real'` for reachable non-provider scopes such as `global` or `function`.\n\n```typescript\nawait setupCraftServiceTestingByRegister(CounterConsumer, {\n CounterConsumer: provideCounterConsumer(),\n Counter: 'real',\n});\n```\n\n### Raw provider\n\nUse the provider returned by `provideX(...)` for `toProvide` or `manuallyProvidedAtRoot` services.\n\n```typescript\nawait setupCraftServiceTestingByRegister(RootCounter, {\n RootCounter: provideRootCounter(),\n ParentCounter: provideParentCounter(),\n ChildCounter: provideChildCounter(),\n});\n```\n\n### Raw mock object\n\nUse a plain object when you want to override the public service shape.\n\n```typescript\nawait setupCraftServiceTestingByRegister(CounterConsumer, {\n CounterConsumer: provideCounterConsumer(),\n Counter: {\n $self: vi.fn(() => 12),\n increment: vi.fn(),\n },\n});\n```\n\n### `'notReached'`\n\nUse `'notReached'` only when the service is on a branch fully pruned by an ancestor mock.\n\n```typescript\nawait setupCraftServiceTestingByRegister(RootCounter, {\n RootCounter: provideRootCounter(),\n ParentCounter: {\n incrementParent: vi.fn(),\n },\n ChildCounter: 'notReached',\n});\n```\n\n## Return Value\n\nThe function resolves to:\n\n- `sut`: the resolved service under test\n- `mocks`: only the services that were actually mocked in the register\n\nEntries marked as `'real'`, `'notReached'`, or provided through raw providers are not exposed in `mocks`.\n\n## App Start Hooks\n\nReachable real services declared with `appStart: true` must be acknowledged explicitly.\n\n```typescript\nconst { sut } = await setupCraftServiceTestingByRegister(\n Dashboard,\n {\n Dashboard: provideDashboard(),\n AuthSession: 'real',\n Analytics: 'real',\n },\n {\n appStart: {\n AuthSession: 'run',\n Analytics: 'ignore',\n },\n },\n);\n```\n\n`'run'` injects the real service and awaits its `onAppStart(...)` hook. `'ignore'` documents that the test intentionally skips it. Mocked services and `'notReached'` branches do not require `appStart` entries.\n\n## Boundary-Only Mode\n\n`setupCraftServiceTestingByRegister.boundaryOnly(...)` is the recommended mode\nwhen the test should mock only browser or platform edges.\n\n```typescript\nconst { sut, mocks } = await setupCraftServiceTestingByRegister.boundaryOnly(\n Dashboard,\n {\n toProvideRegister: {\n Dashboard: provideDashboard(),\n FeatureConfig: provideFeatureConfig({ env: 'test' }),\n },\n boundaryRegister: {\n LocalStorageService: {\n getItem: vi.fn(() => 'cached'),\n },\n ConsoleService: 'real',\n },\n appStart: {\n AuthSession: 'run',\n },\n },\n);\n```\n\n- `toProvideRegister` contains real providers required by reachable services.\n- `boundaryRegister` contains the explicit decision for each reachable browser boundary.\n- non-boundary services cannot be mocked in this mode.\n- descendants of a mocked boundary are pruned and do not need entries.\n\nThe helper never decides automatically from the test environment. Use `'real'`\nwhen the real boundary is appropriate, and provide a mock when the test needs\ndeterministic platform behavior.\n\n## Alias\n\n`setupTestingService` is a backward-compatible alias of `setupCraftServiceTestingByRegister`.\n\n## See Also\n\n- [craftService](/guide/app/craft-service)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
"path": "/guide/testing/type-level",
|
|
489
|
+
"title": "Type-level tests",
|
|
490
|
+
"body": "# Type-level tests\n\nSome of what a template guarantees is not observable at runtime — it is in the\ntypes. These assertions resolve **entirely at compile time**: no `TestBed`, no\nDOM, no fixture, no component instantiation.\n\n**Use them when** a regression would be silent: an element quietly stops\nrendering under a condition, a binding is repointed at another context member, a\nhandler becomes imperative, a prop changes shape.\n**Not instead of** runtime tests — they prove the template's _contract_, not\nwhat the user ends up seeing. Pair them with\n[template tests](/guide/testing/components#component-template).\n\n## The three questions they answer\n\nMost of what you'll write falls into one of these. Each is expanded below.\n\n| You want to prove… | Use |\n| ------------------------------------------------------------ | -------------------------------------------------- |\n| an element renders **only under a condition** | `TemplateRendersNamedElementWhen` with `{ when }` |\n| a binding is rendered **for every item of a non-empty list** | the same, with `{ when: { items: 'nonEmpty' } }` |\n| a **property is used** on a named element | `TemplateNamedElementRendersStateWhen` |\n| an element property delegates to a context method | `TemplateNamedElementDelegatesToContext` |\n| a component logic field has a specific service output | `ComponentLogicOutputOf` + `ResolvedServiceOutput` |\n\n::: warning Experimental\nThis contract is the least settled part of `@craft-ts`. The assertions below\nwork and are covered by the library's own tests, but their **names and\nergonomics are still moving** — expect the DX to get shorter and more readable\nbefore it stabilises. Pin the version if you rely on them heavily.\n:::\n\n## Setting it up\n\nThe assertions build on two type helpers, published for applications on a\ndedicated subpath:\n\n```ts\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n```\n\nThey are **types only** — nothing is emitted, so importing them costs nothing at\nruntime.\n\nAn assertion is the pair: a helper that computes a boolean type, wrapped in\n`Expect<Equal<…, true>>`. If the computed type stops being `true`, the file stops\ncompiling.\n\n`ComponentTemplateOf` gets you the template type to assert on:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n```\n\n`ComponentLogicOutputOf` gets the value returned by the component logic\nfactory. This lets you assert the type of a field returned by the factory,\ninstead of checking only that the component declares a dependency:\n\n```ts\nimport type { ComponentLogicOutputOf } from '@craft-ts/component';\nimport type { ResolvedServiceOutput } from '@craft-ts/core';\n\ntype FullDemoLogic = ComponentLogicOutputOf<typeof FullDemoCraft>;\ntype TodoStoreOutput = ResolvedServiceOutput<typeof TodoStore, {}>;\n\ntype StoreIsTodoStore = Expect<Equal<FullDemoLogic['store'], TodoStoreOutput>>;\n```\n\n`ResolvedServiceOutput` is used here because it preserves the reactive brands\npresent on the value produced by `yield* TodoStore()`.\n\n### Running them with Vitest\n\nType assertions fail at **compile** time, so they need something to typecheck the\nfile. `tsc --noEmit` is enough, but Vitest can run them alongside your runtime\ntests:\n\n```shell\nvitest typecheck\n```\n\nPut the assertions in a `*.test-d.ts` file and Vitest reports a failing type as a\nfailing test, in the same run and the same output as everything else. Vitest's\nown `expectTypeOf` / `assertType` work there too, and compose with the helpers\nbelow.\n\n::: tip Give them a home\nA type assertion nobody typechecks proves nothing. Either keep them in files\ncovered by `vitest typecheck`, or make sure `tsc --noEmit` runs over them in CI.\n:::\n\n## The template contract\n\n`SetupTestComponentTemplate` resolves the template without `TestBed`, a DOM,\nthe factory, or runtime providers. The component tuple contains the references\nallowed for children:\n\n```ts\ntype CounterTemplateTest = SetupTestComponentTemplate<\n typeof Counter,\n [typeof CounterButton, typeof PlusIcon]\n>;\n```\n\nThe resolver traverses elements, directives, `each`, `defer`, and child\ncomponents. A component reference missing from the tuple becomes a type\ndiagnostic. Visited components are tracked so recursive templates do not create\na resolution loop.\n\nThe contract also checks the required public props of `ComponentNode` and keeps\nthe concrete child component reference. Dynamic component unions produce a\ndedicated diagnostic; split them into static branches so they can be checked at\nthe type level. Components owned by another package form an explicit boundary\nand should be tested with that package's harness.\n\nThe available assertions can verify elements, their exact props, event\narguments, generator callbacks, and outputs. For example, start with a\ncomponent whose `disabled` property is nested inside the `counter` state:\n\n\n\n\nThe type assertions inspect the template returned by `Counter`; they do not\ninstantiate the component or render a DOM fixture:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype HasButton = Expect<\n Equal<TemplateHasElement<CounterTemplate, 'button'>, true>\n>;\n\n// TemplateHasElementWithProps checks the exact prop set of the matching node.\ntype HasCounterClass = Expect<\n Equal<\n TemplateHasElementWithProps<\n CounterTemplate,\n 'div',\n { readonly class: string }\n >,\n true\n >\n>;\n\ntype HasClick = Expect<\n Equal<\n TemplateHasYieldableEvent<CounterTemplate, 'button', 'click', [MouseEvent]>,\n true\n >\n>;\n\ntype HasNestedDisabledBinding = Expect<\n Equal<TemplateRendersStateWhen<CounterTemplate, 'counter.disabled'>, true>\n>;\n```\n\nThese checks detect different regressions at compile time. Removing the\n`button`, changing the `click` callback to an imperative function, changing\nits event arguments, or replacing `counter.disabled` with another context\nmember makes the corresponding assertion fail. The `TemplateHasElementWithProps`\ncheck also catches an unexpected extra, missing, or differently typed prop.\n\nPrimitive properties follow the same contract as events. For derived state, use\n`craftComputed` in the `state` insertion:\n\n\n\n\nHere, `counter` is created by the component factory and returned in its\ncontext. The template receives that context, and the branded\n`context.counter.disabled()` read is the binding that the type assertion\nchecks:\n\n```ts\ntype HasDerivedDisabledBinding = TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.disabled'\n>;\n\ntype _HasDerivedDisabledBinding = Expect<\n Equal<HasDerivedDisabledBinding, true>\n>;\n```\n\nIf the template were accidentally changed to use another member, the\nassertion would fail:\n\n```ts\ntype UsesWrongBinding = Expect<\n Equal<\n TemplateRendersStateWhen<\n ReturnType<ComponentTemplateOf<typeof Counter>>,\n 'counter.enabled'\n >,\n false\n >\n>;\n```\n\nThe callback is executed by the Craft driver before the DOM property is\nwritten. The assertion verifies the exact binding source without a fixture or\nDOM.\n\n`computed` remains a synchronous signal when called directly\n(`counter.disabled()`) and in a template context.\n\nTemplates supplied to `each` and `defer` are also resolved. When a `defer`\ndirectly loads a Craft component, that component must appear in the registry.\nUnder this contract, DOM and output callbacks must be generators or branded\nCraft methods; ordinary imperative callbacks produce a diagnostic.\n\nBranded Craft methods are projected into the template context as yieldable\ncallbacks:\n\n```ts\nbutton(\n {\n *click() {\n yield* context.counter.increment(2);\n },\n },\n '+',\n);\n```\n\nThe renderer executes these callbacks with the Craft driver. Render callbacks\n(text, classes, styles, `each`, `defer`) remain synchronous.\n\n## Conditional visibility and named elements\n\nReactive values exposed by Craft primitives and services keep their property\nname in the template type. They remain synchronously readable in templates,\nwhile their name brand is available to `ifBlock` and the visibility contract.\nUse `ifBlock` to retain the condition and its branches in the VNode contract:\n\n\n\n\n\nThe local name is rendered as `data-craft-name`; `data-craft-root` remains an\ninternal tracking attribute.\n\n### Proving an element renders only under a condition\n\nA named element is asserted with its **full component identity** —\n`'<Component>:<tag>:<localName>'` — and the visibility path it sits behind:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype CanIncrement = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n CounterTemplate,\n 'Counter:button:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\nWhen the element is truly unconditional, omit `when` (or use an empty object).\nAn element inside an `ifBlock` or `each` requires its visibility condition;\nomitting `when` deliberately returns `false` for such an element. Keep the\ncomplete `ComponentTemplateOf` type if you want editor completion for the\ncomponent prefix of the identity. Using `ReturnType<...>`\npreserves the element and tag, but loses the component name used for the most\nuseful completion suggestions:\n\n```ts\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype DisplayNewTodoNameInput = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n FullDemoTemplate,\n 'FullDemoCraft:input:TodoNameToAddInput'\n >,\n true\n >\n>;\n```\n\nWhen editing the second argument, the available identities are proposed from\nthe template, for example `FullDemoCraft:input:TodoNameToAddInput` and\n`FullDemoCraft:button:AddTodoButton`. `{ when: {} }` is equivalent to omitting\nthe third argument: both assert that the element is unconditional. For an\nelement inside an `ifBlock` named `isAuth`, use `{ when: { isAuth: true } }`.\n\nThe same visibility contract can identify an element through branded direct\ncontent. Here, `brandedStatus` is not selected by its text; its brand proves that the\n`span` renders that value in the authenticated branch:\n\n```ts\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype StatusIsRenderedWhenAuthenticated = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'brandedStatus',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\nconst test = await setupCraftComponentTemplateTest.byRegister(Counter, {\n context: {\n isAuth: markYieldableValue(signal(true), 'isAuth'),\n brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),\n },\n register: {},\n});\n\nconst brandedStatusElement = test.locator('span', {\n content: 'brandedStatus',\n});\nbrandedStatusElement?.textContent;\ntest.destroy();\n```\n\nBecause the element is conditional, `brandedStatusElement` is typed as\n`HTMLSpanElement | undefined`. After `updateContext` and `detectChanges`, the\nsame locator returns `undefined` while the branch is absent.\n\n### Proving a binding renders for every item of a non-empty list\n\n`each` contributes `<listName>: 'nonEmpty'` to the visibility path, so you can\nassert what every item renders — here a translated label exposed by an\n`insertSelect` insertion:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { insertSelect, state } from '@craft-ts/core';\nimport { craftComponent, each, span } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRendersNamedElementWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst ItemList = craftComponent(\n 'ItemList',\n {},\n function* () {\n const items = yield* state(\n 'items',\n [{ key: 'first' }, { key: 'second' }],\n insertSelect('item', ({ state: selectedItem }) => ({\n translatedLabel: craftComputed(function* () {\n return `translated:${(yield* selectedItem()).key}`;\n }),\n })),\n );\n return { items };\n },\n ({ items }) =>\n each(items, { track: (item) => item.key }, (_item, index) =>\n span(\n 'itemLabel',\n { 'aria-label': items.selectItem(index)?.translatedLabel },\n () => items.selectItem(index)?.translatedLabel() ?? '',\n ),\n ),\n);\n\ntype ItemListTemplate = ReturnType<ComponentTemplateOf<typeof ItemList>>;\n\ntype HasTranslatedLabel = Expect<\n Equal<\n TemplateRendersNamedElementWhen<\n ItemListTemplate,\n 'ItemList:span:itemLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n\ntype RendersTranslatedLabel = Expect<\n Equal<\n TemplateRendersStateWhen<\n ItemListTemplate,\n 'items.selectItem.translatedLabel',\n { when: { items: 'nonEmpty' } }\n >,\n true\n >\n>;\n```\n\n\n\n### Proving a property is used on a named element\n\nThe same visibility paths verify that a state really feeds a rendered binding,\nand that a yieldable action is available on a named element — `'click:increment'`\nreads as \"the `click` action on the element named `increment`\":\n\n```typescript\nimport { craftMethod, state } from '@craft-ts/core';\nimport { button, craftComponent, ifBlock } from '@craft-ts/component';\nimport type {\n ComponentTemplateOf,\n TemplateRenderAvailableActionWhen,\n TemplateRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\nconst Counter = craftComponent(\n 'Counter',\n {},\n function* () {\n const isAuth = yield* state('isAuth', true);\n const isAdult = yield* state('isAdult', true);\n const increment = craftMethod('increment', function* () {\n return undefined;\n });\n\n return { isAuth, isAdult, increment };\n },\n ({ isAuth, isAdult, increment }) =>\n ifBlock(\n isAuth,\n () => button('increment', { click: increment }, () => isAdult()),\n () => [],\n ),\n);\n\ntype CounterTemplate = ReturnType<ComponentTemplateOf<typeof Counter>>;\n\ntype RendersAdultState = Expect<\n Equal<\n TemplateRendersStateWhen<\n CounterTemplate,\n 'isAdult',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n\n// The key is `${event}:${localName}`.\ntype CanIncrementWhenAuthenticated = Expect<\n Equal<\n TemplateRenderAvailableActionWhen<\n CounterTemplate,\n 'click:increment',\n { when: { isAuth: true } }\n >,\n true\n >\n>;\n```\n\n\n\n`TemplateRendersStateWhen` recognizes branded reads that contribute to visible\ntext or other render bindings such as `class` and `style`. Both assertions\nreturn `false` when the state or action exists only under a visibility branch\nthat is incompatible with `when`.\n\n`each` adds `<listName>: 'nonEmpty'` for its item template and\n`<listName>: 'empty'` for its empty template. Interactive helpers must use the\nnamed form (`button('increment', {}, '+')`): ESLint\n`craft-ts/require-interactive-local-name` requires the literal first argument,\nand `assertInteractiveElementNamed` requires that `data-craft-name` to be unique\nin the app. `craft-ts/template-element-name-unique` still forbids two\n`tag:localName` pairs in the same component template, including across\nconditional branches.\n\n### Proving a named property uses a specific state\n\n`TemplateNamedElementRendersStateWhen` combines the named-element identity,\nthe element property, and the context path. All three arguments are constrained\nby the template type, so editors can complete the element identity, the\navailable property names, and the available context paths:\n\n```ts\nimport type {\n ComponentTemplateOf,\n TemplateNamedElementRendersStateWhen,\n} from '@craft-ts/component';\nimport type { Equal, Expect } from '@craft-ts/dev-tools/testing';\n\ntype FullDemoTemplate = ComponentTemplateOf<typeof FullDemoCraft>;\n\ntype RemoveButtonUsesRemoveLoading = Expect<\n Equal<\n TemplateNamedElementRendersStateWhen<\n FullDemoTemplate,\n 'FullDemoCraft:button:RemoveTodoButton',\n 'disabled',\n 'store.remove.isLoading'\n >,\n true\n >\n>;\n```\n\nFor a reactive property binding, keep the read inside a render callback so\nthe context marker remains visible to the template contract:\n\n```ts\nbutton('RemoveTodoButton', {\n disabled: store.remove.isLoading,\n});\n```\n\nThis assertion proves that the `disabled` binding on the named remove button\nis driven by `store.remove.isLoading`; it does not instantiate the component or\nobserve the DOM.\n\n### Proving a named event delegates to a context method\n\n`TemplateNamedElementDelegatesToContext` checks the same relationship for a\ngenerator event callback:\n\n```ts\nimport type { TemplateNamedElementDelegatesToContext } from '@craft-ts/component';\n\ntype AddButtonClickUsesAddMutation = Expect<\n Equal<\n TemplateNamedElementDelegatesToContext<\n FullDemoTemplate,\n 'FullDemoCraft:button:AddTodoButton',\n 'click',\n 'store.add.mutate'\n >,\n true\n >\n>;\n```\n\nThe source callback must delegate with `yield*`:\n\n```ts\nbutton('AddTodoButton', {\n *click() {\n yield* store.add.mutate(title().trim());\n },\n});\n```\n\nThe named identity prevents a different button's `click` handler from\nsatisfying the assertion.\n\n## Pitfalls\n\n**Asserting `true` where the answer is `false`.** These helpers return a\nboolean type, so `Expect<Equal<…, true>>` is the assertion. Writing the helper\nalone proves nothing — it just computes a type nobody checks.\n\n**Naming the element is what makes it addressable.** A `button('increment', …)`\ncarries the local name that `'Counter:button:increment'` resolves. Without it\nthere is no identity to assert on.\n\n**Imperative callbacks are rejected.** Under this contract, DOM and output\ncallbacks must be generators or branded Craft methods; an ordinary function\nproduces a diagnostic.\n\n**A `defer` that loads a Craft component** requires that component to be present\nin the registry tuple.\n\n**The ergonomics are known to be rough.** `Expect<Equal<Helper<ReturnType<\nComponentTemplateOf<typeof X>>, …>, true>>` is a lot of ceremony for one\nassertion. Shorter façades are being explored; until then, alias what repeats:\n\n```ts\ntype Tpl = ReturnType<ComponentTemplateOf<typeof Counter>>;\ntype Assert<T extends true> = Expect<T>;\n```\n\n## See Also\n\n- [Testing components](/guide/testing/components) — the runtime half\n- [Testing services](/guide/testing/services)\n- [Architecture rules](/guide/testing/architecture) — constraints on the whole app graph\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
"path": "/learn",
|
|
494
|
+
"title": "Learn @craft-ts",
|
|
495
|
+
"body": "# Learn @craft-ts\n\nThis is the guided path. You build **one app**, from an empty component to a\nrouted, tested feature — adding exactly one idea per step.\n\nIf you are looking for a specific answer instead, go to the\n[Guide](/guide/) (organised by task) or search.\n\n## What you will build\n\nA task list. It starts as three lines in a component and ends up with server\ndata, optimistic updates, URL state, a validated form, a typed route and tests.\n\n| Step | What you add |\n| ------------------------------------------------------- | ------------------------------------------- |\n| [1. Your first state](/learn/01-first-state) | `craftComponent`, `state` |\n| [2. Derive instead of duplicate](/learn/02-derive) | computed + methods |\n| [3. Move logic out of the component](/learn/03-service) | `craftService` |\n| [4. Compose services](/learn/04-compose) | generators, `yield*` |\n| [5. Load server data](/learn/05-load-data) | `query` |\n| [6. Write server data](/learn/06-mutate-data) | `mutation`, optimistic updates |\n| [7. Put state in the URL](/learn/07-url-state) | `queryParams` |\n| [8. Build a form](/learn/08-forms) | `insertForm`, validators |\n| [9. Wire up routing](/learn/09-routing) | `craftRoute`, compile-time DI check |\n| [10. Test what you wrote](/learn/10-testing) | testing by register, architecture rules |\n\nThen: [Where to go next](/learn/next).\n\n## Before you start\n\nYou need a TypeScript application and Node.js 20.19+ (or 22.12+). No prior\nknowledge of generators, RxJS or signals internals is required — each is\nintroduced when it first earns its place.\n\n::: tip Read in order\nEvery step builds on the previous one's code. Skipping ahead works, but step 4\nis where the mental model clicks — don't skip that one.\n:::\n\n::: warning Experimental\n`@craft-ts` and this documentation are both experimental. APIs can still move\nbetween minor versions.\n:::\n\n<div style=\"text-align: right; margin-top: 2rem\">\n\n[Start → Your first state](/learn/01-first-state)\n\n</div>\n"
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
"path": "/learn-effect",
|
|
499
|
+
"title": "Learn CraftTS with Effect",
|
|
500
|
+
"body": "# Learn CraftTS with Effect\n\nThis is the guided path for teams that already use [Effect](https://effect.website/)\nand want CraftTS to own the UI, reactivity and application graph.\n\nIf you are evaluating CraftTS from an existing Effect codebase, start with\n[Effect users: start here](/learn-effect/00-start-here). It explains what stays\nin Effect, what moves to Craft's UI model, and how to try the integration in\nfifteen minutes.\n\nYou start with a Craft component, then move the domain work into Effect programs:\n`Layer` provides services, `Effect<A, E, R>` carries success, typed failures and\nrequirements, and Craft adapters expose those programs as reactive resources.\n\n## What you will build\n\n| Step | What you add |\n| --- | --- |\n| [0. Effect users: start here](/learn-effect/00-start-here) | boundary, quickstart and adapter choice |\n| [1. Start with a Craft component](/learn-effect/01-first-component) | `craftComponent`, templates, native Craft state |\n| [2. Derive UI state](/learn-effect/02-derive) | `craftComputed`, `yield*`, precise dependencies |\n| [3. Put the domain in Effect](/learn-effect/03-effect-domain) | `Effect`, tagged errors, `Context.Service`, `Layer` |\n| [4. Load data with Effect](/learn-effect/04-load-data) | `queryEffect`, typed errors and defects |\n| [5. Write data with Effect](/learn-effect/05-write-data) | `mutationEffect`, `asyncProcessEffect`, reactive updates |\n| [6. Provide Layers and route the app](/learn-effect/06-layers-routing) | app/route Layers, DI proofs, type-safe routes |\n| [7. Build forms and validate boundaries](/learn-effect/07-forms-validation) | Effect Schema, forms, typed submit errors |\n| [8. Test the graph](/learn-effect/08-testing) | Effect service mocks, Craft registers, architecture tests |\n| [9. Call server functions — POC](/learn-effect/09-server-functions) | client/server boundary, `serverFunction`, `executeEffect` |\n\n## Before you start\n\nYou need a TypeScript application, Node.js 20.19+ (or 22.12+), and basic\nknowledge of generators. The guide uses Effect 4 RC and the beta CraftTS\npackages:\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep `@craft-ts/core`, `@craft-ts/component` and `@craft-ts/effect` on the same\nCraftTS version. `@craft-ts/effect` has `effect` as a peer dependency.\n\n::: warning Experimental APIs\n\nCraftTS and this Effect integration are still experimental. The Effect bridge,\nthe server-function API and their types can change between beta releases. The\nserver-function chapter is deliberately labelled **proof of concept**: use it\nto explore the model, not as a final production contract.\n\n:::\n\n::: tip The central rule\n\nCraft owns the reactive boundary. Effect owns domain programs and their\ndependencies. Do not create a `stateEffect`: use native Craft `state` for UI\nstate, and use `queryEffect`, `mutationEffect` or `asyncProcessEffect` when an\nEffect program crosses into a Craft resource.\n\n:::\n\n<div style=\"text-align: right; margin-top: 2rem\">\n\n[Start → Craft component](/learn-effect/01-first-component)\n\n</div>\n"
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
"path": "/learn-effect/00-start-here",
|
|
504
|
+
"title": "Effect users: start here",
|
|
505
|
+
"body": "# Effect users: start here\n\nThis page is for teams that already use Effect and are evaluating CraftTS for\nthe frontend.\n\nThe important distinction is this:\n\n> You do not need to replace your domain model or your Effect programs. You do\n> need to adopt Craft's UI model for components, templates, reactive state,\n> forms and routing.\n\nEffect remains the place for domain programs, typed failures, services and\n`Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity,\nloading, cancellation and URL state.\n\n## The boundary in one picture\n\n```mermaid\nflowchart LR\n UI[\"Craft component and template\"] --> R[\"Craft resource\\nqueryEffect / mutationEffect\"]\n R --> P[\"Effect program\\nEffect<A, E, R>\"]\n P --> L[\"Layer<R>\"]\n L --> I[\"Craft injector\\napplication / route / component\"]\n R --> V[\"Reactive Craft readers\\nvalue / loading / exceptions\"]\n V --> UI\n```\n\nThe two sides have different responsibilities:\n\n| Concern | Effect | CraftTS |\n| --- | --- | --- |\n| Domain rules | `Effect<A, E, R>` | consumes the result |\n| Services | `Context.Service` + `Layer` | provides the Layer at a Craft scope |\n| Business failures | tagged errors in `E` | typed exceptions to render or handle |\n| UI state | not the owner | `state`, `queryParams`, derived readers |\n| Loading and cancellation | Effect runtime | `queryEffect`, `mutationEffect`, `asyncProcessEffect` |\n| Components and templates | not the owner | `craftComponent` and typed hyperscript |\n\n`yield*` appears on both sides, but it does not mean the same thing. Inside an\nEffect program it reads an Effect service or runs another Effect. Inside a Craft\nfactory it declares a Craft dependency or crosses the boundary through an\nEffect adapter.\n\n## A 15-minute quickstart\n\nThe goal is one page that loads a user from an Effect program and renders the\nresult through a Craft query.\n\n### 1. Install the matching packages — 2 minutes\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep the Craft packages on the same version. See the\n[compatibility and maturity matrix](/resources/effect-compatibility) before\nusing this in a production application.\n\n### 2. Define the domain program — 4 minutes\n\nThis code is ordinary Effect code. It does not import Craft.\n\n\n\nThe component will call `loadUser`, but it will not resolve\n`UserRepositoryService`. The nearest `Layer` will provide it.\n\n### 3. Cross the boundary with `queryEffect` — 4 minutes\n\nThe adapter turns `Effect<User, UserNotFound, UserRepositoryService>` into a\nCraft resource with loading, value and exception readers.\n\n\n\nDo not call `Effect.runPromise` or subscribe inside the component. The resource\nowns execution, cancellation and the transition between loading, success and\nfailure.\n\n### 4. Provide the Layer and install the bridge — 3 minutes\n\nInstall the bridge once at application bootstrap. Provide the Effect Layer at\nthe same Craft scope where the operation is used.\n\n\n\nRun the application with your normal frontend command. The executable version\nof this example is also covered by the docs test suite.\n\n### 5. Verify the boundary — 2 minutes\n\n```shell\nnpx nx test docs\nnpx nx typecheck demo-effect\nnpx nx test demo-effect\n```\n\nThe docs test target now performs three checks: it transpiles every TypeScript\nor TSX code fence in `learn-effect`, type-checks the complete snippets under\n`tests/snippets/learn-effect`, and executes their Vitest tests. The transpilation\ncheck is intentionally syntax-focused because several excerpts are meant to be\ncopied into an existing Craft or Effect generator; complete examples receive\nthe stronger typecheck and runtime coverage. The Effect demo covers success,\ntyped business errors, defects, application Layers and route-scoped Layers.\n\nFor a runnable starter that keeps this boundary intentionally small, use the\nrepository's [`quickstart-effect`](https://github.com/craft-ts/craft-ts/tree/main/apps/quickstart-effect)\napplication. It is wired into the same ESLint, EffectTS diagnostics and\narchitecture checks that a new Effect frontend should adopt.\n\n## Which adapter should I choose?\n\n| Situation | Adapter |\n| --- | --- |\n| Local toggle, draft or selection | `state` |\n| Server or domain read | `queryEffect` |\n| Explicit write | `mutationEffect` |\n| Reactive Effect derived from Craft state | `computedEffect` |\n| Export, refresh or other explicit command | `asyncProcessEffect` |\n| One Effect in a guard or resolver | `runEffect` |\n| URL filters and pagination state | native Craft `queryParams` |\n\nThere is intentionally no `stateEffect`: local UI state belongs to Craft; an\nEffect is introduced when a computation, I/O operation or service dependency\ncrosses into the UI.\n\n## Continue from here\n\n- Read the [full Effect learning path](/learn-effect/).\n- Check [compatibility and maturity](/resources/effect-compatibility).\n- Follow the [progressive adoption plan](/resources/effect-adoption).\n- For the detailed API contract, read [Using Effect with CraftTS](/guide/advanced/effect).\n"
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
"path": "/learn-effect/01-first-component",
|
|
509
|
+
"title": "1. Start with a Craft component",
|
|
510
|
+
"body": "# 1. Start with a Craft component\n\n**Goal:** render a reactive task list before introducing Effect.\n\nEffect users do not need to replace their domain model or Effect programs. They\ndo need to adopt Craft's UI model: a component is a function with a generator\nlogic factory and a typed template:\n\n```typescript\nimport { craftComponent, div, h1, li, ul, each } from '@craft-ts/component';\nimport { state } from '@craft-ts/core';\n\ntype Task = { readonly id: string; readonly title: string; readonly done: boolean };\n\nexport const Tasks = craftComponent(\n 'Tasks', // name: stable component name used by tooling and the graph\n {}, // meta: providers, styles and host configuration\n function* () { // logic factory: creates the component context\n const tasks = yield* state('tasks', [ // name: state identifier\n { id: '1', title: 'Learn Craft components', done: true },\n { id: '2', title: 'Add the first Effect program', done: false },\n ] satisfies Task[]); // initial value: the seeded task list\n\n return { tasks };\n },\n ({ tasks }) => [ // template: turns the context into rendered nodes\n h1('Tasks'),\n ul(\n each(\n tasks, // source: the reactive collection to render\n { track: (task) => task.id }, // options: stable identity for each item\n (task) => li(task.title), // render: creates one node per task\n ),\n ),\n ],\n);\n```\n\nThere is no class, decorator, selector or separate HTML file. The template is\ntyped hyperscript. A component has four responsibilities:\n\n| Argument | Responsibility |\n| --- | --- |\n| `'Tasks'` | stable name used by tooling and the graph |\n| `{}` | providers, styles and host configuration |\n| `function*` | create the component context and yield dependencies |\n| template | turn that context into nodes |\n\n`tasks` is a Craft reader. Yield it when a generator reads it; pass it directly\nto a template binding. The renderer tracks the exact binding that reads it.\n\n## Bootstrap once\n\nThe root component is provided in the app config and mounted by\n`bootstrapCraft`:\n\n```typescript\n// app.config.ts\nimport { provideCraftRootComponent } from '@craft-ts/component';\nimport { craftAppConfig } from '@craft-ts/core';\nimport { App } from './app';\n\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app/app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n## Where Effect fits\n\nCraft owns reactive UI state and rendering. Effect owns domain operations — work\nthat may fail with typed errors or depend on services. In the next step, we will\nconnect an Effect program to Craft so the component can render its result\nwithout managing subscriptions or fibers.\n\n## What you gained\n\nA selectorless, typed component with fine-grained rendering. The next step adds\nderived UI state without duplicating data.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn-effect/)\n\n[2. Derive UI state →](/learn-effect/02-derive)\n\n</div>\n"
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"path": "/learn-effect/02-derive",
|
|
514
|
+
"title": "2. Derive UI state",
|
|
515
|
+
"body": "# 2. Derive UI state\n\n**Goal:** calculate UI state from the source of truth, and understand the\n`yield*` rule that Craft and Effect share.\n\nUse `craftComputed` for synchronous derivations. Its factory is a generator when\nit reads a Craft value:\n\n```typescript\nimport { craftComputed, state } from '@craft-ts/core';\n\nconst tasks = yield* state('tasks', [] as Task[]);\nconst remaining = craftComputed('remaining', function* () {\n return (yield* tasks()).filter((task) => !task.done).length;\n});\n```\n\nThe template can bind `remaining` directly. Craft re-runs only the binding that\ndepends on it.\n\n## The shared dependency vocabulary\n\nBoth runtimes use generators, but they solve different problems:\n\n```typescript\nconst tasks = yield* TaskList(); // Craft service\nconst access = yield* AccessPolicyService; // Effect service inside an Effect\nconst value = yield* resource.value(); // Craft reader inside a derivation\n```\n\nThe rule is the same: yield what the current function does not own. A Craft\nfactory yields Craft dependencies; an Effect program yields Effect dependencies.\nThe adapter connects the two at a deliberate boundary.\n\n## Do not duplicate domain state in the component\n\nThe component should not subscribe to an Effect, convert an Effect to a signal\nby hand, or start a fiber in a template callback. Those approaches hide loading,\ncancellation and failure state from Craft. Instead:\n\n1. Keep the domain operation as `Effect<A, E, R>`.\n2. Expose it through a Craft Effect-aware primitive.\n3. Derive the display state from the resulting Craft resource.\n\nThe next step defines the domain operation and the services it requires.\n\n## What you gained\n\nDerived state that stays reactive and a clear division: Craft derives the UI;\nEffect composes the domain program.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Start with a Craft component](/learn-effect/01-first-component)\n\n[3. Put the domain in Effect →](/learn-effect/03-effect-domain)\n\n</div>\n"
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
"path": "/learn-effect/03-effect-domain",
|
|
519
|
+
"title": "3. Put the domain in Effect",
|
|
520
|
+
"body": "# 3. Put the domain in Effect\n\n**Goal:** define typed business failures and services without making the Craft\ncomponent know how they are provided.\n\n## Typed failures are values\n\nEffect's tagged errors map naturally to Craft's exception channel:\n\n```typescript\nimport { Context, Data, Effect } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\ntype User = {\n readonly id: string;\n};\n\ntype UserRepository = {\n readonly find: (userId: string) => Effect.Effect<User | undefined>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport function loadUser(userId: string) {\n return Effect.gen(function* () {\n const repository = yield* UserRepositoryService;\n const user = yield* repository.find(userId);\n if (!user) return yield* new UserNotFound({ userId });\n return user;\n });\n}\n```\n\nThe program has the shape `Effect<User, UserNotFound, UserRepositoryService>`.\n`yield* UserRepositoryService` gets the repository from the Effect context;\n`yield* repository.find(userId)` then runs the `Effect` returned by its method.\n`UserNotFound` is a business outcome that the UI can handle. An unexpected\ndefect raised by `Effect.die` remains a technical error; it is not turned into a\nbusiness exception.\n\n`Data.TaggedError` creates a **yieldable error** in Effect v4, so this is the\nidiomatic form inside `Effect.gen`:\n\n```typescript\nif (!user) return yield* new UserNotFound({ userId });\n```\n\nThe explicit equivalent is `yield* Effect.fail(new UserNotFound({ userId }))`;\nthere is no `Effect.failed` constructor. At the Craft boundary, yield the\neffect through `runEffect(...)` instead of yielding the error instance directly.\n\n## Define an Effect service\n\nUse `Context.Service` for the contract and a `Layer` for the implementation:\n\n```typescript\nimport { Context, Effect, Layer } from 'effect';\n\ntype AccessPolicy = {\n readonly decide: (userId: string) => Effect.Effect<AccessDecision, UserNotFound>;\n};\n\nexport class AccessPolicyService extends Context.Service<\n AccessPolicyService,\n AccessPolicy\n>()('app/AccessPolicyService') {}\n\nexport const AccessPolicyLive = Layer.sync(AccessPolicyService)(() => ({\n decide: (userId) => findAccessDecision(userId),\n}));\n\nexport function checkUserAccess(userId: string) {\n return Effect.gen(function* () {\n const policy = yield* AccessPolicyService;\n return yield* policy.decide(userId);\n });\n}\n```\n\nThe component calls `checkUserAccess`; it does not call `AccessPolicyService`\nand does not know which Layer implements it.\n\nWhen a Craft factory genuinely needs a service member, narrow it explicitly with\n`effectService` rather than resolving an untracked value:\n\n```typescript\nimport { effectService } from '@craft-ts/effect';\n\nconst { decide } = yield* effectService(\n AccessPolicyService,\n ({ decide }) => ({ decide }),\n);\n```\n\nPrefer exposing a domain operation such as `checkUserAccess` to a component. The\nselector form is useful for a Craft service or adapter that deliberately owns\nthe boundary and wants the graph to record only the members it uses.\n\n## Derive Craft state from the Effect service\n\n`craftComputed` stays synchronous: it derives a Craft reader. Let\n`queryEffect` execute the Effect operation, then derive a display value from the\nquery resource:\n\n```typescript\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\n\nconst accessQuery = yield* queryEffect('accessQuery', {\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n\nconst accessLabel = craftComputed('accessLabel', function* () {\n return (yield* accessQuery.value())?.label ?? 'Loading…';\n});\n```\n\nThe chain is: `queryEffect` runs `checkUserAccess`, the active `Layer` provides\n`AccessPolicyService`, and `accessLabel` reacts to the query's Craft value. The\ncomputed does not call the Effect service or start an Effect itself.\n\n## Run a standalone Effect\n\nFor a low-level bridge, `runEffect` lets a Craft generator yield an Effect while\npreserving its typed error channel:\n\n```typescript\nimport { Effect } from 'effect';\nimport { runEffect } from '@craft-ts/effect';\n\nconst name = yield* runEffect(Effect.succeed('Ada'));\n```\n\nUse the adapters in the next chapters for application data. They resolve the\nEffect requirement `R` through the nearest `provideLayer(...)` and keep loading,\nvalue and exception state in the Craft resource.\n\n## Install the bridge once\n\nThe bridge teaches Craft how to execute a yielded Effect. Install it during app\nbootstrap, not in every loader:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn tests, call `installCraftEffectBridge()` in `beforeEach` and dispose the\nreturned function in `afterEach`.\n\n## What you gained\n\nAn Effect domain with typed failures, explicit service requirements and swappable\nLayers. The next step puts that program behind a reactive `queryEffect`.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 2. Derive UI state](/learn-effect/02-derive)\n\n[4. Load data with Effect →](/learn-effect/04-load-data)\n\n</div>\n"
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
"path": "/learn-effect/04-load-data",
|
|
524
|
+
"title": "4. Load data with Effect",
|
|
525
|
+
"body": "# 4. Load data with Effect\n\n**Goal:** expose an `Effect<A, E, R>` as a Craft query.\n\n## The operation being loaded\n\n`queryEffect` receives a domain function that returns an Effect. Here is the\n`loadUserProfile` used by the query below; the data is mocked so the example\ncan show each result channel:\n\n```typescript\n// profile-domain.ts\nimport { Data, Effect } from 'effect';\n\nexport type ProfileScenario =\n | 'success'\n | 'not-found'\n | 'session-expired'\n | 'database-down';\n\ntype Profile = { readonly name: string };\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport class Unauthorized extends Data.TaggedError('Unauthorized')<{\n readonly reason: string;\n}> {}\n\nexport function loadUserProfile(scenario: ProfileScenario) {\n return Effect.gen(function* () {\n // Simulate the latency of a backend request.\n yield* Effect.sleep('400 millis');\n\n switch (scenario) {\n case 'not-found':\n return yield* new UserNotFound({ userId: 'user-404' });\n case 'session-expired':\n return yield* new Unauthorized({ reason: 'session expired' });\n case 'database-down':\n return yield* Effect.die(new Error('database unavailable'));\n case 'success':\n return { name: 'Ada Lovelace' } satisfies Profile;\n }\n });\n}\n```\n\n`loadUserProfile` does not run when it is declared. It returns an\n`Effect<Profile, UserNotFound | Unauthorized>`, which represents a backend\nrequest and which the query runs whenever its parameters trigger the loader.\n\n## `queryEffect`\n\nThe adapter has the same lifecycle as `query`, but its loader returns an Effect:\n\n```typescript\nimport {\n type Input,\n craftComponent,\n ifBlock,\n matchBlock,\n p,\n} from '@craft-ts/component';\nimport { craftComputed } from '@craft-ts/core';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile, type ProfileScenario } from './profile-domain';\n\nconst Profile = craftComponent(\n 'Profile',\n {},\n function* (profileScenarioInput: Input<ProfileScenario>) {\n const profile = yield* queryEffect(\n 'profile',\n {\n params: profileScenarioInput,\n loader: ({ params }) => loadUserProfile(params),\n },\n ({ resource, exceptions }) => ({\n hasProfile: craftComputed('hasProfile', () => resource.hasValue()),\n currentError: craftComputed('currentError', function* () {\n return (yield* exceptions()).loader;\n }),\n }),\n );\n\n return { profile };\n },\n ({ profile }) => [\n ifBlock(profile.isLoading, () => p('Loading…')),\n /* bind profile.value() or match profile.exceptions().loader here */\n ],\n);\n```\n\n`queryEffect` is a Craft query with an Effect loader. It owns cancellation,\nloading state, the last value and typed exceptions. Its `Effect` requirements are\nresolved by the active Layer. Here, `profileScenarioInput` is the reactive input\nsource: changing it reruns `loadUserProfile`; there is no `method` or manual\n`profile.call(...)` because the input drives the query.\n\n## The three result channels\n\n| Effect outcome | Craft outcome |\n| -------------------------- | -------------------------------------------------- |\n| `Effect.succeed(value)` | query value; the generator resumes with `value` |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` |\n| `Effect.die(defect)` | technical resource error, not a business exception |\n\nInterruption is cancellation. It does not become a user-facing exception.\n\nHandle typed errors exhaustively with `matchBlock.exhaustive` or with a route\nexception handler:\n\n```typescript\nmatchBlock.exhaustive(profile.exception, '_tag', {\n UserNotFound: () => p('No profile matches that user.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nWhen the Effect is used in a route guard or resolver directly, prefer\n`yield* runEffect(program)`. A bare `yield* program` executes at runtime but\ndoes not advertise `E` to Craft's compile-time route exception analysis.\n\n## Reactive Effect computations\n\nWhen the Effect itself is the value derived from Craft dependencies, use\n`computedEffect`:\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst profile =\n yield *\n computedEffect('profile', function* () {\n const userId = yield* currentUserId();\n return loadUserProfile(userId);\n });\n```\n\n`computedEffect` reruns the factory when a Craft dependency changes, executes\nthe returned Effect with the nearest `provideLayer(...)`, and exposes the same\nresource lifecycle as a query: `value`, `status`, `isLoading`, cancellation and\ntyped exceptions. It is useful for a derived Effect value; use `queryEffect`\nwhen the input/loader boundary and query cache semantics are the important\npart of the feature.\n\n## Synchronous params and methods\n\nThe `params` factory remains synchronous: it may read Craft dependencies, but it\nmust not construct an Effect or yield an Effect service. Use `computedEffect`\nfor an asynchronous derivation. A `method` only maps its arguments to params;\nthe loader is the only Effect-aware callback:\n\n```typescript\nconst profile =\n yield *\n queryEffect('profile', {\n params: function* () {\n const input = yield* currentUserInput();\n return resolveProfileParams(input);\n },\n loader: ({ params }) => loadUserProfile(params),\n });\n\nconst profileByMethod =\n yield *\n queryEffect('profileByMethod', {\n method: (input: UserInput) => resolveProfileParams(input),\n loader: ({ params }) => loadUserProfile(params),\n });\n```\n\nThe Effect ESLint rule rejects Effect values and Effect service reads inside\n`params`, methods, `craftComputed(...)`, and `craftEffect(...)`, keeping the\nquery boundary synchronous and deterministic. Only the loader may return an\nEffect.\n\nFor purely synchronous local state, use native Craft values and `state`; there\nis intentionally no `stateEffect`:\n\n```typescript\nconst request = yield * state('request', 'support');\n```\n\nUse Effect for computations, I/O and service dependencies.\n\n## What you gained\n\nEffect's typed result becomes a reactive Craft resource without a manual\nsubscription or signal conversion.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 3. Put the domain in Effect](/learn-effect/03-effect-domain)\n\n[5. Write data with Effect →](/learn-effect/05-write-data)\n\n</div>\n"
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
"path": "/learn-effect/05-write-data",
|
|
529
|
+
"title": "5. Write data with Effect",
|
|
530
|
+
"body": "# 5. Write data with Effect\n\n**Goal:** model writes and explicit processes as Effects while retaining Craft's\nmutation lifecycle.\n\n## `mutationEffect`\n\nUse `method` for the synchronous argument-to-params mapping and `loader` for\nthe Effect program:\n\n```typescript\n const saveTask = yield* mutationEffect('saveTask', {\n method: (input: { readonly title: string }) => input,\n loader: ({ params }) => saveTaskEffect(params),\n});\n\nyield* saveTask.mutate({ title: 'Ship the Effect guide' });\n```\n\nThe mutation exposes loading, value and typed `exceptions().loader` just like a\nnative Craft mutation. Compose it with the list query using the normal Craft\ninsertion:\n\n```typescript\nconst tasksQuery = yield* queryEffect(\n 'tasks',\n {\n params: () => ({ done: false }),\n loader: ({ params }) => listTasksEffect(params),\n },\n insertReactOnMutation(saveTask, {\n reload: { onMutationSuccess: true },\n }),\n);\n```\n\nUse an optimistic insertion when the result can be predicted locally:\n\n```typescript\nconst tasksQuery = yield* queryEffect(\n 'tasks',\n {\n params: () => ({ done: false }),\n loader: ({ params }) => listTasksEffect(params),\n },\n insertReactOnMutation(saveTask, {\n optimisticPatch: {\n title: ({ mutationParams }) => mutationParams.title,\n },\n reload: { onMutationException: true },\n }),\n);\n```\n\nEffect remains responsible for the operation and its typed errors; Craft remains\nresponsible for when the resource is refreshed and what the UI renders.\n\n## `asyncProcessEffect`\n\nUse `asyncProcessEffect` for an explicit process that is not a read cache or a\nwrite resource:\n\n```typescript\nconst refresh = yield* asyncProcessEffect('refresh', {\n method: (userId: string) => userId,\n loader: ({ params }) => refreshProfile(params),\n});\n\nyield* refresh.method('user-ada');\n```\n\nThis is useful for refresh actions, exports, background commands and similar\nflows. Do not turn every Effect into an async process: choose `queryEffect` for\nserver state, `mutationEffect` for writes and `asyncProcessEffect` for explicit\ncommands.\n\n## Validate arguments before the Effect\n\nEffect Schema can be used anywhere Craft accepts a Standard Schema. Convert it\nonce:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst SaveTask = Schema.toStandardSchemaV1(\n Schema.Struct({ title: Schema.String }),\n);\n\nconst saveTask = yield* mutationEffect('saveTask', {\n methodSchema: SaveTask,\n method: (input) => input,\n loader: ({ params }) => saveTaskEffect(params),\n});\n```\n\nFor a schema failure, Craft reports a parse exception. For a business rule such\nas “this title already exists”, return a tagged Effect error from the Effect\nprogram instead. See [Effect Schema](/guide/state/schema-validation#effect-schema)\nfor synchronous and asynchronous decoding rules.\n\n## What you gained\n\nTyped Effect writes with Craft's loading, cancellation, cache invalidation and\noptimistic-update machinery. Next, provide the services that those programs\nrequire at app and route scope.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 4. Load data with Effect](/learn-effect/04-load-data)\n\n[6. Provide Layers and route the app →](/learn-effect/06-layers-routing)\n\n</div>\n"
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
"path": "/learn-effect/06-layers-routing",
|
|
534
|
+
"title": "6. Provide Layers and route the app",
|
|
535
|
+
"body": "# 6. Provide Layers and route the app\n\n**Goal:** make Effect requirements explicit at the same scopes as your Craft\ninjectors.\n\n## Provide one application Layer\n\n`provideLayer` builds an Effect context and stores it on the Craft injector:\n\n```typescript\nimport { Layer } from 'effect';\nimport { provideLayer } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive)),\n ],\n});\n```\n\nThe Layer is built once at that injector level. Child injectors reuse the parent\ncontext and add their own services.\n\n## Add a route Layer\n\nKeep route providers in a named tuple so the compile-time proof can inspect it:\n\n```typescript\nconst teamRouteProviders = [\n provideLayer(SupportTeamLive),\n] as const;\n\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(\n () => import('./team').then(({ default: component }) => component),\n teamRouteProviders,\n ),\n },\n]);\n```\n\nThe team query can require `SessionService | TeamContextService` while the\ncomponent only sees `TeamOverview`. Route-scoped resources are closed when the\nroute injector is destroyed, so Layer scopes do not leak across navigation.\n\n## Layer scopes follow Craft provider scopes\n\n`provideLayer(...)` is a normal Craft provider, so the same Effect context can\nbe attached at every Craft scope that accepts providers:\n\n| Scope | Where to put `provideLayer(...)` | Lifetime and visibility |\n| --- | --- | --- |\n| Application | `appConfig.providers` | shared by the whole application |\n| Route | the route's `providers` array | shared by that route and its children |\n| Component | `craftComponent` meta `providers` | limited to that component subtree |\n| Primitive | a primitive config's `providers` | limited to that primitive |\n| Insertion | the containing primitive's `providers` | inherited by its insertion callbacks and methods |\n\nFor example, a component or a primitive can provide a local implementation\nwithout changing the application Layer:\n\n```typescript\nconst Profile = craftComponent(\n 'Profile',\n { providers: [provideLayer(AccessPolicyLive)] },\n /* … */\n);\n\nconst profile = yield* queryEffect('profile', {\n providers: [provideLayer(AccessPolicyLive)],\n params: () => 'user-ada',\n loader: ({ params }) => checkUserAccess(params),\n});\n```\n\nAn insertion receives the primitive's injector, so its generators and methods\nsee the primitive's Layer as well. There is no separate `provideLayer` argument\non an insertion today; use the containing primitive's `providers` to scope it.\nIf two services must be provided at the same scope, merge them into one Layer:\n\n```typescript\nproviders: [provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive))]\n```\n\nChild scopes inherit the parent context and can add a more local implementation\nof a service. Their scopes are closed with the corresponding Craft injector.\n\n## Prove requirements at compile time\n\nEffect requirements are not regular Craft services, so add an explicit proof:\n\n```typescript\nimport type { Effect } from 'effect';\nimport type { AppProvidedDependencyValuesOf, CanRun } from '@craft-ts/core';\nimport type {\n EffectRequirementsCheckedDI,\n ProvidedEffectServicesOf,\n} from '@craft-ts/effect';\n\ntype AppProvidedEffectServices = AppProvidedDependencyValuesOf<\n typeof appConfig\n>;\n\ntype CheckTeam = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n AppProvidedEffectServices | ProvidedEffectServicesOf<typeof teamRouteProviders>\n>;\ntype CanRunTeam = CanRun<CheckTeam>;\n```\n\nRemove `SupportTeamLive` and `CanRunTeam` becomes a useful type error naming the\nmissing Effect service. This is the Effect equivalent of Craft's `RouteCheckedDI`.\n\n## Route errors are still exhaustive\n\n`queryEffect` and `runEffect` make tagged Effect errors visible to Craft's route\nexception analysis. Keep the route map exhaustive:\n\n```typescript\nconst { routes } = craftRoutes('app', [\n {\n path: '',\n ...loadCraftComponent(() => import('./profile')),\n handleExceptions: {\n UserNotFound: craftExceptionHandler(/* … */),\n Unauthorized: craftExceptionHandler(/* … */),\n },\n },\n]);\n\nassertExhaustiveRouteExceptions(routes);\n```\n\n## Keep URL state in Craft\n\nURL state is a UI concern, so it stays a native `queryParams` primitive even in\nan Effect application:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst Search = Schema.String;\nconst searchCodec = {\n decode: Schema.decodeUnknownSync(Search),\n encode: Schema.encodeSync(Search),\n};\n\nconst filters = yield* queryParams('filters', {\n state: {\n search: {\n fallbackValue: '',\n codec: searchCodec,\n },\n },\n});\n\nconst users = yield* queryEffect('users', {\n params: () => filters(),\n loader: ({ params }) => searchUsers(params),\n});\n```\n\nThe codec remains synchronous, as required by `queryParams`, but validation and\nencoding now come from an Effect `Schema`. Replace `Schema.String` with a\ntransformation schema when the URL representation differs from the value used\nby the component.\n\nThere is no `queryParamsEffect`: Craft synchronises the URL, while the Effect\nloader reacts to the resulting typed params.\n\n## What you gained\n\nEffect Layers now follow Craft's app and route scopes, their requirements are\nchecked, and typed Effect failures cannot silently disappear at a route.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 5. Write data with Effect](/learn-effect/05-write-data)\n\n[7. Build forms and validate boundaries →](/learn-effect/07-forms-validation)\n\n</div>\n"
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
"path": "/learn-effect/07-forms-validation",
|
|
539
|
+
"title": "7. Build forms and validate boundaries",
|
|
540
|
+
"body": "# 7. Build forms and validate boundaries\n\n**Goal:** use Craft forms for interaction and Effect Schema for data that crosses\na boundary.\n\n## Forms remain Craft state\n\nA form derives from the state it edits. Use `insertForm`, validators and\n`insertFormSubmit` exactly as in the regular Craft path:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n\nconst createTask = yield* mutationEffect('createTask', {\n methodSchema: CreateTaskInput,\n method: (input) => input,\n loader: ({ params }) => createTaskEffect(params),\n});\n\nconst draft = yield* state(\n 'draft',\n { title: '', description: '' },\n insertForm(\n insertFormSchema(CreateTaskInput),\n insertFormSubmit(createTask),\n ),\n);\n\nconst form = draft.form();\n```\n\nThe exact field insertions depend on the shape of your component, but the\nownership rule does not change: form values and validity are Craft state; the\nsubmit operation is an Effect-backed mutation.\n\n## For more advanced form validation\n\nThis Effect example is enough when the main concern is validating the payload\nat the boundary. For richer form behaviour — field-level rules, conditional\nvalidators, cross-field validation, nested forms or custom typed exceptions —\nuse Craft's dedicated form API with `insertFormAttributes`, `cValidate` and\n`insertSelectFormTree`. See the [Forms guide](/guide/forms/) for this\nalternative. Effect Schema can still be kept on `methodSchema` to validate the\nfinal payload before the Effect runs.\n\n## Effect Schema at the boundary\n\nEffect Schema is not passed directly to Craft. Convert it to Standard Schema:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst CreateTaskInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n title: Schema.String,\n description: Schema.String,\n }),\n);\n```\n\nUse it for `methodSchema`, `paramsSchema` or `loaderSchema`. The decoded output\nis what the rest of the application sees. A synchronous schema is safe for\nmethod arguments and local writes; asynchronous decoding belongs in\n`loaderSchema` or in the Effect loader itself.\n\n## Submit failures\n\nValidation failures are parse exceptions. Domain failures stay in Effect's\ntyped error channel and become `exceptions().loader` on the mutation. The form\ncan therefore distinguish:\n\n- invalid input, before the Effect runs;\n- a business rejection returned by the server/domain;\n- an unexpected defect that should reach the technical error boundary.\n\nDo not put navigation, toasts or state writes inside a computed exception list.\nDrive those actions after `submit()` or from an explicit process.\n\n## What you gained\n\nCraft owns the interaction model and Effect owns the domain validation or write;\ntheir error channels remain distinct and typed.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 6. Provide Layers and route the app](/learn-effect/06-layers-routing)\n\n[8. Test the graph →](/learn-effect/08-testing)\n\n</div>\n"
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
"path": "/learn-effect/08-testing",
|
|
544
|
+
"title": "8. Test the graph",
|
|
545
|
+
"body": "# 8. Test the graph\n\n**Goal:** test Effect programs, their Layers and the Craft boundary without\nmocking the whole application.\n\n## Test an Effect service with a partial mock\n\n`mockEffectService` provides a Layer and makes every unstubbed member fail\nloudly if the test accidentally uses it:\n\n```typescript\nimport { Effect } from 'effect';\nimport { mockEffectService } from '@craft-ts/effect';\n\nconst register = {\n AccessPolicyService: mockEffectService(AccessPolicyService, {\n decide: () => Effect.succeed(expectedDecision),\n }),\n};\n```\n\nFor production-like tests, provide the real Layer. For focused tests, stub only\nthe selected members and let `UnstubbedEffectMember` expose an unexpected read.\n\n## Test the Craft service or component by register\n\nCraft tests still use a register derived from the Craft dependency graph:\n\n```typescript\nconst { sut } = await setupCraftServiceTestingByRegister(\n AccessDecisionService,\n {\n AccessPolicyService: mockEffectService(AccessPolicyService, {\n decide: () => Effect.succeed(expectedDecision),\n }),\n },\n);\n```\n\nThe exact register also includes regular Craft services, `'real'`,\n`'notReached'` or `provideX()` entries when those nodes are reachable. Effect\nservice mocks cover the Effect side; the Craft register proves the full graph is\naccounted for.\n\n## Test the bridge and adapters\n\nInstall and dispose the bridge per test suite:\n\n```typescript\nlet dispose: () => void;\n\nbeforeEach(() => {\n dispose = installCraftEffectBridge();\n});\n\nafterEach(() => {\n dispose();\n});\n```\n\nCover at least one example of each channel: a typed failure becomes a Craft\nexception, `Effect.die` rejects as a technical error, and aborting the owning\nresource interrupts the Effect.\n\n## Architecture checks\n\nThe static graph includes the Effect backend automatically when\n`analyzeDependencyGraph` runs. It exposes typed nodes for `effect-service`,\n`effect-operation` and `effect-layer`, together with `requires-service`,\n`provided-by-layer` and `composes-layer` relations. You can therefore write\nrules against Effect concepts just as you do against Craft nodes:\n\n```typescript\nconst effectServices = graph.nodes('effect-service');\nconst effectOperations = graph.nodes('effect-operation');\nconst effectLayers = graph.nodes('effect-layer');\nconst serviceRequirements = graph.edges('requires-service');\n```\n\nThe built-in checks can then be kept beside the app's architecture tests:\n\n```typescript\nassertCraftEffectNoNetwork(graph.graph);\nassertCraftEffectNoImperativeSync(graph.graph);\nassertDeclarativeArchitecture(graph.graph);\n```\n\nFor a project-specific invariant, inspect these typed nodes and relations in a\ncustom assertion and fail the architecture test when the rule is violated. If\nyou need to add concepts that the built-in Effect backend does not model, use\nthe [`DependencyGraphNodeRegistry` and `DependencyGraphCollector`](/guide/testing/extensible-architecture-graph).\nThe Effect graph is already collected; no extra collector is needed just to\napply rules to its services, operations or Layers.\n\n## What you gained\n\nTests that mirror the real Craft and Effect graphs: real composition by default,\nnarrow mocks at the boundary, and architecture rules for the invariants that\ntypes alone cannot keep armed.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 7. Build forms and validate boundaries](/learn-effect/07-forms-validation)\n\n[9. Call server functions — POC →](/learn-effect/09-server-functions)\n\n</div>\n"
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
"path": "/learn-effect/09-server-functions",
|
|
549
|
+
"title": "9. Call server functions — proof of concept",
|
|
550
|
+
"body": "# 9. Call server functions — proof of concept\n\n::: danger Not a final contract\n\nThe server-function integration is currently a **proof of concept**. The file\nconventions, transport, middleware composition and production integration are\nnot definitive yet and may change. Use this chapter to understand the current\nexperiment and to build demos; do not treat it as a stable deployment API.\n\n:::\n\n**Goal:** understand the current client → registry → Effect server path.\n\n## The current shape\n\nAn exposed function has a server implementation and a client facade:\n\n```text\nusers/list.fn-client.ts\nusers/list.fn-serveur.ts\n │\n └── HTTP/RPC → createServer registry → Effect handler\n```\n\nThe client imports only the server function's type. It must not import the\nserver implementation at runtime.\n\n## Define the server implementation\n\nThe current experimental API takes an identifier, an input schema and an\nexposure mode. The handler returns an Effect:\n\n```typescript\n// users/list.fn-serveur.ts\nimport { serverFunction } from '@craft-ts/core';\nimport { Effect, Schema } from 'effect';\n\nconst inputSchema = Schema.toStandardSchemaV1(\n Schema.Struct({ filter: Schema.String }),\n);\n\nexport const listUsers = serverFunction(\n 'demo.users.list',\n inputSchema,\n { exposure: 'client' },\n).handler(({ input }) =>\n Effect.gen(function* () {\n const repository = yield* UserRepository;\n return yield* repository.list(input.filter);\n }),\n);\n```\n\nThe server handler's success, typed errors and Effect requirements are the source\nof truth. Do not duplicate a result type or an error list manually.\n\n## Define the client facade\n\n```typescript\n// users/list.fn-client.ts\nimport { createServerFunctionClient } from '@craft-ts/core';\nimport type { listUsers as ServerListUsers } from './list.fn-serveur';\n\nexport const getUsers = createServerFunctionClient<typeof ServerListUsers>(\n 'demo.users.list',\n);\n```\n\nThe component uses the facade like a typed function. Wrap it in `queryEffect` or\n`mutationEffect` if the call belongs to a resource lifecycle:\n\n```typescript\nimport { isCraftException } from '@craft-ts/core';\n\nconst users = yield* queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) =>\n Effect.gen(function* () {\n const result = yield* Effect.promise(() => getUsers(params));\n if (isCraftException(result)) return yield* Effect.fail(result);\n return result;\n }),\n});\n```\n\nThe exact transport adapter is still experimental. The repository's\n`demo-with-server-function` app currently uses `createServer`, `executeEffect`\nand a local HTTP bridge.\n\n## Register and execute on the server\n\n```typescript\nconst application = createServer({\n functions: [listUsers],\n execute: executeEffect(runtimeLayer).run,\n});\n```\n\nThe runtime Layer supplies server-only services such as a repository or the\ncurrent user. Never import secrets, credentials or server implementations into\na client module.\n\n## Middleware and security\n\nThe current demo also shows Effect middleware:\n\n```typescript\nconst audited = effectServerMiddleware('demo.audit', ({ next }) =>\n Effect.gen(function* () {\n yield* Effect.log('before');\n const result = yield* Effect.exit(next());\n yield* Effect.log('after');\n return yield* result;\n }),\n);\n```\n\nMiddleware may add typed failures, resolve Effect services and run before/after\nhooks. Client claims remain untrusted; authenticate and authorize again on the\nserver, then publish only verified values to the handler context.\n\n## Current limitations\n\nTreat these as constraints of the POC, not promises of the final design:\n\n- the browser transport and development plugin are local experimental adapters;\n- client/server file boundaries are checked by the current architecture graph,\n but deployment integration is still evolving;\n- middleware APIs and the server registry may be renamed or reshaped;\n- the server must re-check authorization even if the client has a matching\n Effect Layer.\n\nSee the running examples in\n[`apps/demo-with-server-function`](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function)\nand the server-function architecture plan in the repository when this work is\npromoted out of the prototype area.\n\n## What you gained\n\nYou can experiment with typed Effect server calls while keeping an explicit\nclient/server boundary. Keep this chapter isolated from stable application\ncontracts until the POC is replaced by a final server-function API.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 8. Test the graph](/learn-effect/08-testing)\n\n[Back to the overview →](/learn-effect/)\n\n</div>\n"
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
"path": "/learn/01-first-state",
|
|
554
|
+
"title": "1. Your first state",
|
|
555
|
+
"body": "# 1. Your first state\n\n**Goal:** get a reactive value on screen, and meet the two building blocks you\nwill use in every step — `craftComponent` and a primitive.\n\n## Install\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nThe packages are currently published on the `beta` channel. The component\npackage contains the functional renderer, while `core` contains the reactive\nprimitives used by the component factory.\n\n## A component with state\n\nA Craft component is a **function**, not a class. It takes a name, meta, a logic\nfactory, and a template:\n\n\n\nFour arguments, and each has one job:\n\n| Argument | What it is |\n| ------------ | ----------------------------------------------------------- |\n| `'Tasks'` | the component's name — used by the tooling and by host tags |\n| `{}` | meta: providers, styles, host properties (empty for now) |\n| `function*` | the **logic factory** — builds and returns the context |\n| `({ … }) =>` | the **template** — receives that context, returns nodes |\n\nThere is no class, no decorator, no separate HTML file, and no host element\nwrapped around your markup.\n\n## Inputs and outputs\n\nA component's inputs and outputs are just **parameters of the logic factory**,\ntyped with `Input<T>` and `Output<Handler>`:\n\n\n\nAn `Input<T>` **is a yieldable reader** — `yield* user()` reads the current\nvalue. Project nested fields with `deepYieldable` so `user.name` stays a\nreader. An `Output<H>` is a yieldable callback; delegate to it with `yield*`.\n\nAt the call site you pass the reader itself, not a getter:\n\n```typescript\nUserCard({\n user: currentUser,\n onRemove: removeUser,\n});\n```\n\n| Contract | Craft |\n| --- | --- |\n| Input | an `Input<T>` factory parameter |\n| Output | an `Output<H>` parameter, called directly |\n| Component call | `UserCard({ user: u, onRemove: fn })` |\n| Missing required input | **compile error** |\n\nBecause it's a function call, there is no template-binding layer between caller\nand component: a wrong input name or type is a plain TypeScript error.\n\n## Styling the component\n\nStyles go in the meta, and `:scope` is the component's own root:\n\n```typescript\ncraftComponent(\n 'Tasks',\n {\n styles: `\n :scope { display: grid; gap: .5rem }\n .done { text-decoration: line-through }\n `,\n },\n /* … */\n);\n```\n\n`:scope` refers to **the root of this component**. Component styles are scoped\nwith CSS `@scope`, so the rule cannot leak into unrelated components — and Craft\nadds no host element or wrapper around your markup to achieve it. See\n[Encapsulated styles](/guide/components/styles).\n\n## Mounting the root\n\nThe app's root is a Craft component too. `provideCraftRootComponent(App)`\ndesignates it, and the Craft host bootstraps the application:\n\n```typescript\n// app.config.ts\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRootComponent(App)],\n});\n```\n\n```typescript\n// main.ts\nimport { bootstrapCraft } from '@craft-ts/component';\nimport { appConfig } from './app/app.config';\n\nbootstrapCraft({ config: appConfig });\n```\n\n`bootstrapCraft` builds the root injector, runs the app-start hooks, then\nmounts the root component into `<craft-root>`.\n\n## The two rules of a primitive\n\n**1. A primitive is named.** `state('tasks', …)` — the first argument is always\nthe name. It is not decoration: it tags the primitive's injector (`state:tasks`)\nand is what identifies this piece of state in logs, snapshots and observability.\n\n**2. It resolves to the state reference itself**:\n\n```typescript\nconst tasks = yield * state('tasks', []);\n```\n\n`tasks` is a yieldable reader: `yield* tasks()` in a generator, `craftUse(tasks())`\nat a synchronous boundary, or pass `tasks` directly to a template binding.\n\n## What is `yield*` doing there?\n\nThe factory is a generator, and `yield*` is how **this** factory drives\neverything it does not own — primitives and services alike. The same rule\napplies later to every computed and method: each entity yields its own\ndependencies so they show up on **its** graph.\n\nFor now, treat it as \"the way to use a primitive inside a factory\".\n[Step 4](/learn/04-compose) explains what it buys you.\n\n## The template\n\nThe template is a plain function returning nodes built with hyperscript helpers\n— `div`, `ul`, `li`, `button`, and one `h(tag, …)` escape hatch for anything\nwithout a helper:\n\n```typescript\n({ tasks }) => [\n h1('Tasks'),\n ul(\n each(tasks, { track: (task) => task.id }, (task) => li(task.title)),\n ),\n];\n```\n\nPass the reader (`tasks`) to the binding that consumes it. The renderer drives\nthe read; wrapping `() => tasks()` is a synchronous call the yield rules reject.\nUse `each(...)` when the collection controls a node per item. No `*ngFor`, no\nchange detection to think about.\n\n## Writing to it\n\nRight now the state is read-only from the outside. Give it a writer:\n\n```typescript\nconst tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set }));\n\nyield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]);\n```\n\nThat third argument is an **insertion** — the mechanism you'll use in every step\nfrom here on. Step 2 is entirely about it.\n\n## What you gained\n\nA component and a reactive value, both declared as functions, both named, both\nvisible to the tooling — with no class, no constructor and no subscription.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← Overview](/learn/)\n\n[2. Derive instead of duplicate →](/learn/02-derive)\n\n</div>\n"
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"path": "/learn/02-derive",
|
|
559
|
+
"title": "2. Derive instead of duplicate",
|
|
560
|
+
"body": "# 2. Derive instead of duplicate\n\n**Goal:** attach methods and derived values to your state, instead of scattering\nthem across the component.\n\n## The insertion argument\n\nThe last argument of a primitive is an **insertion**: a function that receives\nthe primitive's internals and returns whatever you want exposed on it.\n\n\n\nEverything you return is now on the ref:\n\n```typescript\nyield* tasks(); // the array\nyield* tasks.add('Learn insertions');\nyield* tasks.remaining(); // 1\n```\n\nThe context gives you `state` (the current value as a yieldable reader), `set`\nand `update`. Non-generator insertion methods may return `update(...)` directly\n— the wrapper consumes the write. `remaining` is a `craftComputed`: it does not\nown `state()`, so it yields it. That is how the computed's own dependency graph\nrecords the read.\n\n## The whole component\n\n```typescript\nimport {\n button,\n craftComponent,\n each,\n h1,\n input,\n li,\n ul,\n} from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* state('tasks', [] as Task[], /* … as above … */);\n return { tasks };\n },\n ({ tasks }) => [\n h1(function* () {\n return `Tasks — ${yield* tasks.remaining()} left`;\n }),\n\n input({\n type: 'text',\n placeholder: 'New task…',\n *keydown(event) {\n if (event.key !== 'Enter') return;\n const field = event.target as HTMLInputElement;\n yield* tasks.add(field.value);\n field.value = '';\n },\n }),\n\n ul(\n each(\n tasks,\n { track: (task) => task.id, empty: () => li('Nothing to do 🎉') },\n (task) =>\n li([\n input({\n type: 'checkbox',\n checked: task.done,\n *change() {\n yield* tasks.toggle(task.id);\n },\n }),\n task.title,\n button({\n *click() {\n yield* tasks.remove(task.id);\n },\n }, '×'),\n ]),\n ),\n ),\n ],\n);\n```\n\nTwo template things worth noting. `each(source, options, render)` takes a\n`track` — the stable identity the renderer uses to reuse, move and remove\nnodes — and an optional `empty` branch. Pass the reader itself (`tasks`) rather\nthan `() => tasks()`. When a binding must format or call a method, use a\ngenerator and `yield*`.\n\nThe logic factory is now three lines. That's the point: **behaviour lives on the\nstate, not around it.**\n\n## Control flow\n\nCraft templates are TypeScript, so control flow is made of functions rather than\nsyntax. Each block is a typed function with an explicit contract:\n\n| Block | Purpose |\n| --- | --- |\n| `each` | Renders a collection with stable tracking and an optional empty branch |\n| `ifBlock` | Preserves a conditional branch in the render contract |\n| `matchBlock.exhaustive` | Matches every member of a discriminated union |\n| `defer` | Loads a branch lazily |\n\n`matchBlock.exhaustive` matches on a **discriminant key** of a union and the\nhandler map must cover every member — a missing case is a compile error.\n\n```typescript\nmatchBlock.exhaustive(() => tasksQuery.exceptions().loader, '_tag', {\n TASK_NOT_FOUND: () => p('This task no longer exists.'),\n TASK_FORBIDDEN: () => p('You do not have access to it.'),\n});\n```\n\n### Why not a plain ternary or `switch`?\n\nBecause a raw TypeScript conditional **collapses**. The template type ends up\nholding the *result* of the branch, not the fact that a branch existed:\n\n```typescript\n// works at runtime, but the contract is now opaque\ntasks.isEmpty() ? p('Nothing to do') : ul(/* … */);\n```\n\n`ifBlock` and `matchBlock` keep the condition **and both branches** in the node\ncontract. That is what lets you assert, at compile time, that an element renders\n*only* when a condition holds, or that a label renders for every item of a\nnon-empty list — see [Type-level tests](/guide/testing/type-level). With a\nternary those assertions have nothing to inspect.\n\nThe renderer also uses the block structure to update surgically instead of\nrebuilding the subtree.\n\n::: tip When a ternary is fine\nFor a leaf value — a class name, a piece of text, an attribute — a ternary is\nthe right tool. The rule concerns **structure**: whenever a branch decides\nwhether an element exists, reach for `ifBlock` or `matchBlock`.\n:::\n\n`ifBlock` takes a **named** reactive value as its condition (a primitive ref, or\na value marked with `markYieldableValue`), because that name is what the\nvisibility contract records.\n\n## Reusing behaviour across components\n\nAn insertion factors logic out of a **primitive**. Its counterpart for\n**components** is a directive: `craftDirective` decorates both a component's\nlogic factory and its template, and you attach it with `.pipe(...)`:\n\n```typescript\nexport const Card = craftComponent(\n 'Card',\n {},\n (user: Input<User>) => ({ user: deepYieldable(user) }),\n ({ user }) => div(user.name),\n).pipe(InteractivePermissions);\n```\n\nThe directive can add to the context the template receives — here a\n`permissions` object the component never had to declare — and directives compose\nleft to right. That is how a tooltip, focus management or interaction analytics\nget added to several components without any of them knowing about it.\n\nThe full pattern — writing a directive, what it can require from its host, and\nhow styles compose — is on\n[Directives and `.pipe(...)`](/guide/components/directives). See also\n[Customization](/guide/components/customization) for the three layers of\ncomponent customization, and [Encapsulated styles](/guide/components/styles).\n\n## Every exception a component picks up must be handled\n\nIf a component's factory — or one of its providers — can raise a\n`craftException`, that code becomes part of the component's contract. It has to\nbe dealt with, and the compiler is the one that says so:\n\n```typescript\nexport const Restricted = MyComponent.pipe(\n catchBlock.exhaustive({\n NO_ACCESS: () => p('You do not have access to this data.'),\n }),\n);\n```\n\n`catchBlock.exhaustive` is the one you want most of the time: it renders a\n**fallback**. When the failure happens in the factory or a provider — before the\ntemplate exists — the fallback simply renders alone.\n\nHandle it here and the code disappears from the contract. Leave it and it flows\nup to the route, where `handleExceptions` **must** cover it — a reachable code\nwith no handler doesn't compile, and neither does a handler for a code nothing\ncan produce.\n\n::: warning Where the error actually lands today\nThe compile-time enforcement is at the **route**\n(`assertExhaustiveRouteExceptions`). The component `.pipe(...)` overload is\ncurrently kept permissive to avoid excessive TypeScript instantiation depth, so\nan unhandled code there is caught by runtime dispatch instead. Practical\nconsequence: a component rendered outside any route gets no compile-time\nreminder — handle its codes explicitly.\n\nThe whole rule is on [An unhandled exception doesn't just\ndisappear](/guide/concepts/exceptions).\n:::\n\n`matchBlock.exhaustive` is the sibling for rendering from an exception *value*\nor signal. Reach for `catchTag.exhaustive` only when the reaction is pure\nlogic — a toast, a log — and produces no DOM.\n\n## Several insertions at once\n\nOne insertion function gets crowded fast. Split it and compose with `insertStatePipe`:\n\n\n\nEach function in the pipe receives the same context and contributes its own\nslice. This is what makes behaviour **reusable**: an insertion is just a\nfunction, so it can be extracted, parameterised and shared.\n\n::: tip That's what \"insertions\" are\nThe library ships ready-made ones — storage persistence, optimistic updates,\npagination placeholders, forms. They are the exact same shape as the functions\nyou just wrote. See [Insertions](/guide/concepts/insertions).\n:::\n\n## What you gained\n\nState that carries its own behaviour, a template that only renders, and a\ncomposition mechanism that scales past the first three methods.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 1. Your first state](/learn/01-first-state)\n\n[3. Move logic out of the component →](/learn/03-service)\n\n</div>\n"
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"path": "/learn/03-service",
|
|
564
|
+
"title": "3. Move logic out of the component",
|
|
565
|
+
"body": "# 3. Move logic out of the component\n\n**Goal:** turn your task state into a service other components can use.\n\n## From component factory to `craftService`\n\nThe factory body moves out almost unchanged — it was already a generator:\n\n\n\nA service is the same shape as a component's logic factory: a generator that\nyields what it needs and returns a context. The only additions are a **name** and\na **scope**.\n\n## Using it\n\nThe component now yields the service instead of declaring the state:\n\n```typescript\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* TaskList();\n return { tasks };\n },\n ({ tasks }) => [\n /* unchanged */\n ],\n);\n```\n\n`craftService` returns a helper named after the service — here `TaskList`. There\nis no `injectTaskList` and no class to import.\n\n## Picking a scope\n\n`scope` is the one decision to make. Four you will actually use:\n\n| Scope | Instance | Use it when |\n| ----------- | -------------------------- | ---------------------------------------------------------- |\n| `function` | fresh on every injection | the service belongs to a single component (**start here**) |\n| `toProvide` | one per `provideX()` mount | a parent, or a route, shares it with children |\n| `global` | one for the whole app | genuinely app-wide state |\n| `abstract` | none — a contract | the implementation is decided elsewhere |\n\nDefault to `function`. It needs no provider and it says out loud \"this instance\nis not shared\". Move to `toProvide` the day a child component needs the *same*\ninstance, and provide it at the component or the route:\n\n```typescript\nexport const Tasks = craftComponent(\n 'Tasks',\n { providers: [provideTaskList()] },\n function* () {\n const tasks = yield* TaskList();\n return { tasks };\n },\n ({ tasks }) => [\n /* … */\n ],\n);\n```\n\n::: warning `toProvide` needs an explicit provider\nThe route DI check verifies that the provider is present, and\n[architecture tests](/guide/testing/architecture#assertroutediproofs) keep the\nproof in place.\n:::\n\nThe two remaining scopes (`manuallyProvidedAtRoot`, and the details of\n`abstract`) are covered in [Service scopes](/guide/app/service-scopes).\n\n## Parameterising an instance\n\nA service can take **inputs**: the factory's first parameter is an object the\ncall site supplies. Changing inputs are yieldable readers\n(`CraftServiceInput<T>`) — yield them so the input-to-service edge stays in\nthe graph:\n\n```typescript\nexport const { TaskList } = craftService(\n { name: 'TaskList', providedIn: 'function' },\n function* (inputs: { projectId: CraftServiceInput<string> }) {\n const tasks = yield* state('tasks', [] as Task[] /* … */);\n const projectId = yield* inputs.projectId();\n return tasks;\n },\n);\n```\n\n```typescript\nconst tasks = yield* TaskList({ projectId: currentProjectId });\n```\n\nInputs are how you get several configured instances out of one `function`-scoped\nservice, instead of duplicating it.\n\n## Giving the service its own providers\n\nThe service config also takes `providers`, for dependencies that should be\nscoped to this service rather than to whoever mounts it:\n\n```typescript\nexport const { TaskList } = craftService(\n {\n name: 'TaskList',\n providedIn: 'function',\n providers: [provideTaskApi()],\n },\n function* () {\n const api = yield* TaskApi();\n // …\n },\n);\n```\n\nNote this is a different thing from `provideTaskList()`, which is the helper\n*other* code uses to mount a `toProvide` service.\n\n::: tip There is more to both\nInputs interact with the property shortcuts (`X.property()` is deliberately\nblocked when a service has inputs, so a missing dependency can't hide behind a\ndefault — `X.OmitInputs.property()` opts out). Providers can also be declared\nper primitive, and abstract services turn \"who provides this\" into a decision of\nthe mounting site.\n\nAll of it is on [craftService](/guide/app/craft-service) and [Shaping the public\nAPI](/guide/app/expose-api) — come back once the tutorial is done.\n:::\n\n## Exposing less than everything\n\nA service returns whatever it wants to be public. Here `TaskList` returns the\nwhole `tasks` ref. If a consumer only needs one property, it can say so:\n\n```typescript\nconst remaining = yield* TaskList.remaining();\n```\n\nThe dependency graph then records that only `remaining` was used — which makes\ntests smaller, and is why [step 10](/learn/10-testing) is short.\n\n## What you gained\n\nLogic that is reusable, injectable and testable, declared as a function with a\nname and a scope — no `@Injectable`, no constructor.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 2. Derive instead of duplicate](/learn/02-derive)\n\n[4. Compose services →](/learn/04-compose)\n\n</div>\n"
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
"path": "/learn/04-compose",
|
|
569
|
+
"title": "4. Compose services",
|
|
570
|
+
"body": "# 4. Compose services\n\n**Goal:** understand `yield*` — the one idea the whole library is built on.\n\nThis is the step that makes everything else obvious. Take your time here.\n\n## The problem `yield*` solves\n\nDependencies are easy to hide when a service reaches into a runtime container.\nCraft makes each dependency **visible in the type** by yielding it:\n\n```typescript\nexport const { TaskList } = craftService(\n { name: 'TaskList', providedIn: 'function' },\n function* () {\n const api = yield* TaskApi(); // ← tracked\n\n const tasks = yield* state('tasks', [] as Task[], /* … */);\n return tasks;\n },\n);\n```\n\nNow `TaskList`'s type carries `TaskApi` as a dependency. Everything downstream —\nthe DI check on routes, the testing register, the dependency snapshot — reads\nthat type.\n\n## Why a generator?\n\nA generator is just a function that can hand control back to its caller at each\n`yield`. Craft uses it as a **collection channel**: each `yield*` reports \"I need\nthis\" to the runtime driving the factory, which resolves it and folds it into the\ngraph.\n\nYou don't manage that channel yourself. In practice the whole rule is:\n\n> Every named entity yields what it does not own. A factory, a computed, a\n> method — each one records **its** dependencies with `yield*`.\n\n```typescript\nconst api = yield* TaskApi(); // a service\nconst tasks = yield* state('tasks', []); // a primitive\n```\n\n::: warning A primitive is single-use\nEach `state(...)` / `query(...)` call produces one generator, consumed exactly\nonce. Don't store one and `yield*` it twice.\n:::\n\n## Composing two services\n\n```typescript\nconst { TaskApi } = craftService(\n { name: 'TaskApi', providedIn: 'global' },\n () => ({\n // raw fetch, only to keep this example about composition —\n // see the note below\n fetchAll: () => fetch('/api/tasks').then((r) => r.json()),\n }),\n);\n\nconst { TaskList } = craftService(\n { name: 'TaskList', providedIn: 'function' },\n function* () {\n const api = yield* TaskApi();\n const tasks = yield* state('tasks', [] as Task[], ({ set }) => ({\n // For this demo only; we'll later see why this belongs in a mutation instead.\n load: function* () {\n return yield* set(yield* api.fetchAll());\n },\n }));\n return tasks;\n },\n);\n\nconst { TaskStats } = craftService(\n { name: 'TaskStats', providedIn: 'function' },\n function* () {\n const tasks = yield* TaskList();\n return {\n done: craftComputed('done', function* () {\n return (yield* tasks()).filter((t) => t.done).length;\n }),\n };\n },\n);\n```\n\nNote the factory of `TaskApi` is a plain arrow — a service with no dependencies\ndoesn't need to be a generator.\n\n`TaskStats` does not own `TaskList`. The computed yields `tasks` so **that**\nread is recorded on `done`, not silently closed over from the factory.\n\n::: warning Don't call `fetch` directly in real code\nIt is used here only to keep the example about composition. HTTP goes through\n**`CraftHttpClient`**, which is yieldable — so the request is tracked like any\nother dependency, it is mockable at the [browser\nboundary](/guide/testing/browser-boundaries) in tests, and above all it is what\nturns a failed response into a typed `craftException` you can handle.\n\nA raw `fetch` gives you none of that: no tracking, no boundary, and a rejected\npromise instead of a declared failure. [Step 5](/learn/05-load-data) uses\n`CraftHttpClient` for real, and [step 6](/learn/06-mutate-data) shows the\nexceptions it produces.\n\nThe `craft-ts/prefer-craft-http-client` ESLint rule flags direct `HttpClient`\nusage for the same reason.\n:::\n\n## Taking only what you need\n\n`TaskStats` only reads the array. Say so, and the graph records only that:\n\n```typescript\nconst { TaskStats } = craftService(\n { name: 'TaskStats', providedIn: 'function' },\n function* () {\n const fetchAll = yield* TaskApi.fetchAll(); // one property\n // …\n },\n);\n```\n\nA test for `TaskStats` then has to mock `fetchAll` and nothing else.\n\n## What you gained\n\nThe mental model: **declare with a name, drive with `yield*`, derive the rest.**\nEvery remaining step is a variation on it — `query` yields, `mutation` yields,\nguards yield, route providers yield.\n\n::: tip Going deeper\n`craftGen` lets you write a standalone generator outside a service — useful for\nguards and route helpers. See [Generators](/guide/concepts/generators).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 3. Move logic out of the component](/learn/03-service)\n\n[5. Load server data →](/learn/05-load-data)\n\n</div>\n"
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
"path": "/learn/05-load-data",
|
|
574
|
+
"title": "5. Load server data",
|
|
575
|
+
"body": "# 5. Load server data\n\n**Goal:** replace the hand-rolled `load()` from step 4 with `query`, and get\nloading, error and exception state for free.\n\n## The query primitive\n\n\n\nThree things to read here.\n\n**`params`** is reactive. When what it returns changes, the loader re-runs. It\ncan be a signal, a function, or a generator that yields other services.\n\n**`loader`** is a generator, so it can `yield*` — here `CraftHttpClient`, which\nis the craft-tracked HTTP client. A plain `async` function works too when there\nis nothing to yield.\n\n**The result** is a ref carrying the full async state:\n\n```typescript\ntasksQuery.value(); // Task[] | undefined — never throws\ntasksQuery.isLoading(); // boolean\ntasksQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ntasksQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## In the template\n\n`ifBlock` / `matchBlock` are the structural conditionals (see [step\n2](/learn/02-derive#control-flow)). For a first pass a\nternary chain reads fine — just remember it makes the branch invisible to the\n[type-level assertions](/guide/testing/type-level):\n\n```typescript\nimport { craftComponent, each, li, p, ul } from '@craft-ts/component';\n\nexport const Tasks = craftComponent(\n 'Tasks',\n {},\n function* () {\n const tasks = yield* TaskList();\n return { tasks };\n },\n ({ tasks }) =>\n tasks.isLoading()\n ? p('Loading…')\n : tasks.hasException()\n ? p('Could not load tasks.')\n : ul(\n each(\n () => tasks.value() ?? [],\n { track: (task) => task.id },\n (task) => li(task.title),\n ),\n ),\n);\n```\n\nWhen the branches depend on an exception **code** rather than a boolean, reach\nfor `matchBlock.exhaustive(...)` — the compiler then checks you covered every\ncode:\n\n```typescript\nmatchBlock.exhaustive(() => tasks.exceptions().loader, '_tag', {\n TASKS_FORBIDDEN: () => p('You do not have access to this list.'),\n TASKS_NOT_FOUND: () => p('This list no longer exists.'),\n});\n```\n\nSee [Exceptions as values](/guide/concepts/exceptions).\n\n## Triggering it yourself\n\n`params` re-runs the loader automatically. When the trigger is a user action\ninstead, use `method`:\n\n\n\n## Adding derived values\n\nSame insertion mechanism as step 2 — third argument:\n\n```typescript\nconst { tasksQuery } =\n yield *\n query(\n 'tasksQuery',\n {\n /* … */\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* tasksQuery.count();\n```\n\n## About the flicker\n\nThere isn't one: when `params` change, the previous value stays on screen until\nthe new one resolves. That is the **default**, so paginating never blanks the\nlist.\n\nIf you actually want the value cleared while loading, opt out explicitly:\n\n```typescript\nquery('tasksQuery', {\n params: () => ({ page: page() }),\n preservePreviousValue: () => false,\n loader: /* … */,\n});\n```\n\n## What you gained\n\nServer state with the same shape as local state — named, insertable, tracked —\nand no manual `isLoading` flag.\n\n::: details Beyond the basics\nParallel queries per identifier, business exceptions raised from `params`, typed\nHTTP exception matchers, and reacting to mutations are all on\n[query](/guide/state/server-state).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 4. Compose services](/learn/04-compose)\n\n[6. Write server data →](/learn/06-mutate-data)\n\n</div>\n"
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
"path": "/learn/06-mutate-data",
|
|
579
|
+
"title": "6. Write server data",
|
|
580
|
+
"body": "# 6. Write server data\n\n**Goal:** create a task on the server, and make the list update before the\nrequest even comes back.\n\n## The mutation primitive\n\n`mutation` is `query`'s counterpart for writes. Same shape, triggered explicitly.\n\n\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you can reject input before any\nrequest happens (see below).\n\n## Making the list react\n\nThe interesting part is not the mutation, it's wiring it to the query. That's an\ninsertion — `insertReactOnMutation`:\n\n\n\nThe query now reloads itself whenever `createTask` succeeds. No subscription, no\nevent bus, no manual `refetch()` call at the call site.\n\n## Optimistic updates\n\nReloading costs a round-trip. `optimisticPatch` applies the change immediately\nand reverts it if the mutation fails:\n\n```typescript\ninsertReactOnMutation(renameTask, {\n optimisticPatch: {\n title: ({ mutationParams }) => mutationParams.title,\n },\n reload: { onMutationException: true },\n});\n```\n\nWhile `renameTask` is in flight, `tasksQuery.value()` already shows the new\ntitle. If it throws, the query reloads to get the truth back.\n\n## Rejecting bad input\n\nYou rarely want to send a request you know will fail. Return a `craftException`\nfrom `method` and the loader never runs:\n\n```typescript\nimport { craftException } from '@craft-ts/core';\n\nconst createTask = yield* mutation('createTask', {\n method: (payload: { title: string }) =>\n payload.title.trim().length === 0\n ? craftException({ _tag: 'TITLE_REQUIRED' }, { received: payload.title })\n : payload,\n loader: /* … */,\n});\n\nyield* createTask.mutate({ title: ' ' });\ncreateTask.hasException(); // true\ncreateTask.exceptions().params?.TITLE_REQUIRED;\n```\n\nNote the shape: `exceptions()` is split by **origin** — `params` for what your\n`method` rejected, `loader` for what the request produced. Both are typed from\nthe codes you declared, so the compiler knows `TITLE_REQUIRED` exists and that\n`TITLE_TOO_LONG` doesn't.\n\n### Or let a schema do it\n\nHand-written guards get long as soon as there are several fields. Declare a\nschema instead and the primitive validates the argument for you:\n\n```typescript\nimport { z } from 'zod';\n\nconst CreateTaskSchema = z.object({\n title: z.string().trim().min(1).max(80),\n});\n\nconst createTask = yield* mutation('createTask', {\n methodSchema: CreateTaskSchema,\n method: (payload) => payload, // already validated and typed by the schema\n loader: /* … */,\n});\n```\n\n`methodSchema` validates what `mutate(...)` receives, and `method` then gets the\nschema's **output** value — so a coercion or a `.trim()` in the schema is\nreflected in the type.\n\nAny library implementing `StandardSchemaV1` works — Zod, Valibot, ArkType, or\na hand-written `{ '~standard': … }` object; Effect Schema works too, after one\n[`Schema.toStandardSchemaV1`](/guide/state/schema-validation#effect-schema)\ncall. None of them becomes a dependency of `@craft-ts`. Queries have the same\nhooks for their reactive params (`paramsSchema`) and their result\n(`loaderSchema`).\n\n**Use a schema** when the shape itself is the rule, **a `craftException` from\n`method`** when the rule is business logic — \"this title already exists in the\ncurrent project\" is not something a schema can know. See\n[Schema validation](/guide/state/schema-validation).\n\n::: tip Exceptions as values\nA craft _exception_ is a value you declared and expect to handle. An _error_ is\nthe unexpected kind. Keeping the two apart is what makes the exhaustiveness\nchecks later possible — see [Exceptions](/guide/concepts/exceptions).\n:::\n\n## What you gained\n\nA write path that owns its loading and failure state, and a declarative link\nbetween writes and reads.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 5. Load server data](/learn/05-load-data)\n\n[7. Put state in the URL →](/learn/07-url-state)\n\n</div>\n"
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
"path": "/learn/07-url-state",
|
|
584
|
+
"title": "7. Put state in the URL",
|
|
585
|
+
"body": "# 7. Put state in the URL\n\n**Goal:** make the \"show done tasks\" filter and the page number survive a\nrefresh and a copy-pasted link — without syncing anything by hand.\n\n## `queryParams` is a state that lives in the URL\n\n\n\nReading and writing look like any other state — the URL follows:\n\n```typescript\nfilters(); // { page: 1, showDone: false }\nfilters.page(); // 1\n\nfilters.patch({ showDone: true }); // navigates to ?showDone=true\nfilters.reset();\n```\n\nAnd `?page=3&showDone=true` becomes `{ page: 3, showDone: true }` on load. There\nis no effect to write, no subscription to the `ActivatedRoute`, no\n`skipLocationChange` dance.\n\n## Codecs are mandatory, and that's on purpose\n\nA URL only holds strings. Every parameter must declare how it converts both ways:\n\n```typescript\n{ fallbackValue: 1, codec: { decode, encode } }\n```\n\n`fallbackValue` is what you get when the parameter is absent — so the state type\nis never `undefined`. The decoded type is your application type; the encoded one\nis what appears in the URL. It works the same for dates, enums, arrays\n(`value.split(',')`) and JSON blobs.\n\nCodecs are synchronous, because they run inside the reactive URL computation.\n\nWhen a `decode` throws, the parameter keeps its fallback and the failure surfaces\ninstead of corrupting your state:\n\n```typescript\nif (filters.hasException()) {\n filters.exceptions().parse.page?.code; // 'QueryParamDecodeError'\n}\n```\n\n## Feeding the query\n\nNow connect it to step 5 — the query's `params` reads the URL state:\n\n```typescript\nconst tasksQuery = yield* query('tasksQuery', {\n params: () => ({ page: filters.page(), done: filters.showDone() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/tasks?page=${params.page}&done=${params.done}`,\n success: response<Task[]>(),\n }));\n },\n});\n```\n\nClicking \"next page\" now changes the URL, which re-runs the loader, which\nre-renders the list. One direction of data flow, and the back button works.\n\n## Custom methods, same as always\n\n```typescript\nqueryParams(\n 'filters',\n {\n /* … */\n },\n ({ state, patch }) => ({\n nextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n },\n previousPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page - 1 });\n },\n setPageSize: function* (pageSize: number) {\n return yield* patch({ pageSize, page: 1 });\n },\n }),\n);\n```\n\n## What you gained\n\nShareable, refresh-proof UI state, with no synchronisation code.\n\n::: details Declaring query params on the route itself\n`queryParams` can live directly in a `craftRoutes(...)` entry, so the parameters\nbelong to the route rather than to a component, and can then be retrieved through\ndependency injection. We'll see this after step 9, which introduces routes. See\n[queryParams](/guide/state/url-state) for the full reference.\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 6. Write server data](/learn/06-mutate-data)\n\n[8. Build a form →](/learn/08-forms)\n\n</div>\n"
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
"path": "/learn/08-forms",
|
|
589
|
+
"title": "8. Build a form",
|
|
590
|
+
"body": "# 8. Build a form\n\n**Goal:** a \"new task\" form with validation and a typed submit — derived from\nstate, not declared next to it.\n\n## A form is a state\n\nThere is no `FormBuilder` here. You start from the state you already know, and\n`insertForm` derives the form from it:\n\n\nRead it as: *the form is this shape, and here is what each field requires.* The\nfield tree, the validity, and the exception types are all derived from the state\ntype — you never restate them.\n\n```typescript\nconst form = taskForm.form();\nconst title = form.selectTitle();\n\ntitle()().exceptions.list; // typed list of this field's exceptions\ntitle()().exceptions.byValidator['cRequired'];\n```\n\n`insertSelectFormTree` is lazy. Calling `selectTitle()` materializes the branch\nand registers its validators. Use the returned selected field for DOM binding;\nreading the raw `form.title` field does not activate the branch insertions.\n\n::: warning `insertNoopTypingAnchor`\nIt adds no behaviour. It is a TypeScript anchor that the inference needs to type\nthe field and its exceptions. Every `insertSelectFormTree` needs one — it's a\nknown wart, not a step you can skip.\n:::\n\n## Validators\n\nBuilt-ins cover the usual ground: `cRequired`, `cEmail`, `cMin` / `cMax`,\n`cMinLength` / `cMaxLength`, `cPattern`. Custom ones use `cValidate`, and\n`cAsyncValidate` for server-side checks. Details on\n[Validation](/guide/forms/validation).\n\nFor rules that cover the complete value, add one Standard Schema insertion:\n\n```typescript\ninsertForm(\n insertFormSchema(taskSchema),\n /* field insertions */\n);\n```\n\nSchema issues are projected onto fields by path. The form keeps its input value;\nif the schema transforms values, apply that schema again as the mutation's\n`methodSchema` at submit time.\n\nAttributes are derived too, so conditional UI is a function, not an effect:\n\n```typescript\ninsertFormAttributes(() => ({\n validators: [cRequired()],\n disable: () => createTask.isLoading(),\n hidden: () => !showAdvanced(),\n}));\n```\n\n## Submitting\n\nSubmission is wired to the mutation you wrote in step 6 — that is the whole\ndeclaration:\n\n```typescript\ninsertFormSubmit(createTask);\n```\n\n```typescript\nform({ submit: () => taskForm.form().submit() }, [\n /* fields */\n]);\n```\n\nThe form now knows when it is submitting (`form().submitting()`), whether a\nsubmit was attempted (`form().hasAttemptedSubmit()`), and — the point — **which\nexceptions submission can produce**, inferred from the mutation:\n\n```typescript\ntaskForm.form().submitExceptions();\n```\n\nIf your mutation declares a `TITLE_ALREADY_EXISTS` exception, that code is in the\nunion. Rename it and the compiler tells you where you were handling it.\n\n## Reshaping submit exceptions\n\nServer codes are rarely what the UI wants to show. Refine them in an ordered\npipeline:\n\n```typescript\ninsertFormSubmit(createTask, {\n exceptions: [\n ({ omit }) => omit(['TITLE_ALREADY_EXISTS']),\n ({ submitCraftResource }) => {\n const clash = submitCraftResource.exceptions()?.loader\n ?.TITLE_ALREADY_EXISTS;\n if (!clash) return undefined;\n return craftException({ _tag: 'PICK_ANOTHER_TITLE' }, clash.payload);\n },\n ],\n});\n```\n\nReturning an array replaces the list; returning one exception appends it.\n\n::: warning `success` is not a \"then\" callback\nThe config also accepts `success`, but it runs **inside the derivation of the\nsubmit exception list** and its return value is appended to that list. It exists\nto raise an exception the server reported with a 200 — not to run side effects.\nResetting the form, navigating or showing a toast from there means mutating\nstate inside a computation, and it re-runs whenever the exceptions recompute.\nDrive those from your own code after `submit()`, or from the mutation.\n:::\n\n## What you gained\n\nA form whose validity, field tree and error types are consequences of your state\nand your mutation — so they cannot drift out of sync with them.\n\n::: details Nested and parallel forms\nSub-forms with `insertSubFormField`, several independent forms over the same\nstate, and the full validator reference are on [Forms](/guide/forms/).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 7. Put state in the URL](/learn/07-url-state)\n\n[9. Wire up routing →](/learn/09-routing)\n\n</div>\n"
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
"path": "/learn/09-routing",
|
|
594
|
+
"title": "9. Wire up routing",
|
|
595
|
+
"body": "# 9. Wire up routing\n\n**Goal:** put the tasks page behind a route, and make a missing provider a\n**compile error** instead of a blank screen.\n\nThe headline is this: **navigation only accepts routes that exist**. Not a\n`string` you hope is right — a value checked against the paths your app actually\ndeclares. A typo, a removed route, a missing param: all compile errors, at the\ncall site.\n\nThis is where the dev tooling earns its keep.\n\n## Declare the route\n\nA Craft component is mounted with `loadCraftComponent(...)`, spread into the\nroute:\n\n\n\n`withRetry` wraps the dynamic import, so a chunk that fails to download is\nretried instead of dead-ending the navigation. Keep the import specifier\nliteral — a computed one can't be statically discovered by the bundler.\n\n## Register the paths\n\nDeclaring the collection's paths is what makes navigation type-safe across the\napp:\n\n```typescript\ndeclare module '@craft-ts/core' {\n interface CraftRouterRoutesRegistry {\n App: typeof appRoutes.META_PATHS;\n }\n}\n```\n\nFrom here on, every navigation target is checked against that registry.\n\n## Navigating\n\nTwo ways, both checked against the registry above.\n\n**As a link**, with the `CraftRouterLink` directive:\n\n\n\n**Imperatively**, by yielding the router:\n\n\n\nThe target is `{ to, params?, queryParams? }`, and all of it is checked:\n\n```typescript\nrouter.navigate({ to: 'taks' }); // ✗ not a known path\nrouter.navigate({ to: 'tasks/:taskId' }); // ✗ params.taskId is missing\nrouter.navigate({ to: 'tasks/:taskId', params: { id: '1' } }); // ✗ wrong param\nrouter.navigate({ to: 'tasks/:taskId', params: { taskId: '1' } }); // ✓\n```\n\nNote that `navigate` comes from **yielding** `CraftRouter`, not from injecting\nit — so the dependency is tracked and the route check can see it.\n\n## The check that pays for all of this\n\nEach route component gets its own check: `RouteCheckedDI` compares what the\ncomponent needs against what is actually available at that path, and `CanRun`\nturns a mismatch into a TypeScript error.\n\nThe `tasks` route created above remains visible as the source of truth; the\ncheck below validates that route's component and its `path: 'tasks'` context.\nAn AI can also create this CraftTS routing boilerplate very well — including\nthe lazy import, retry handling, route registry and DI check — from the\ncomponent and path you provide.\n\nDeclare one local alias for your app's context, then one `CanRun` per route:\n\n\n\nThe alias fixes the ambient context once — what the app provides by name\n(`'CraftRouter'`) and by value (`Router | ActivatedRoute`). Each route then\nsupplies three things: the component, the **route inputs** it may bind (a path\nparam like `'taskId'`, or `never`), and a label used in error messages.\n\nA mismatch reads like this:\n\n```\nThe TaskList service is not provided in path: \"tasks\"\nInput \"taskId\" is not provided in path: \"tasks\"\n```\n\nRemember step 3, where `toProvide` was flagged as failing only at runtime? This\nis what closes that hole — **provided the proof stays in the file**. A `CanRun`\nalias that nobody references still compiles; [architecture\ntests](/guide/testing/architecture#assertroutediproofs) are what turn omitting\nit into a failing suite.\n\n::: tip Why one check per route\n`RouteCheckedDI` validates a single component with no recursion between routes,\nso the cost is flat: a file with two hundred routes costs two hundred\nindependent checks and never hits TypeScript's instantiation ceiling. See\n[Scaling routes](/guide/routing/scaling).\n:::\n\n## Prove the exceptions are handled\n\nGuards, matchers and resolvers can raise a `craftException` — and so can a\n**component's own factory or providers**, whose unhandled codes flow up into the\nroute. One call asserts that every reachable code has a handler, and that no\nhandler exists for a code nothing produces:\n\n```typescript\nassertExhaustiveRouteExceptions(appRoutes);\n```\n\nThe ESLint rule `craft-ts/require-assert-exhaustive-route-exceptions` adds it\nfor you.\n\nA component can also handle its own codes with `.pipe(catchTag.exhaustive(...))`,\nwhich removes them from the route's union — see [An unhandled exception doesn't\njust disappear](/guide/concepts/exceptions). Everything else is on\n[Route exception handling](/guide/routing/exception-handling).\n\n## Wire it into the app\n\n\n\n`toRoutes()` returns the runtime routes; `META_DATA` carries the compile-time\ngraph to `craftAppConfig`.\n\n## Make the DI contract enforceable\n\nThe proofs above look ceremonial: unused type aliases, a `CanRun` wrapper, a\ncascade that does not descend into `loadChildren`, a separate check for pending\nand error screens, another for `app.config`. Each piece is small; omitting one\nis silent. TypeScript still compiles.\n\nArchitecture tests collapse that checklist into a single assertion.\n`assertRouteDiProofs` walks the static graph and fails unless every routed\ncomponent — including lazy child collections — and every `craftAppConfig` error\nscreen is hooked to an armed mapper. TypeScript still judges whether a\ndependency is provided; the architecture suite judges whether that judgement\nwas invoked.\n\nAdd it next to `e2e/`, in `architecture/`, then run it in CI. Full setup:\n[Architecture rules](/guide/testing/architecture).\n\n## What the user sees while a route loads\n\nA guard or a resolver that does real work leaves the app frozen on the previous\npage. Swap `provideRouter` for `provideCraftRouter` and render\n`CraftRouterOutlet()` instead of `<router-outlet>`, and the URL commits\nimmediately while the chain runs behind it:\n\n\n\nThose three numbers are the whole waiting story, and they exist so a fast\nnavigation shows **nothing at all**:\n\n| Phase | What is on screen |\n| ----------------------------------- | ------------------------------------------------- |\n| `0 → stayMs` | the previous page — most navigations resolve here |\n| `stayMs → +blankMs` | a blank surface: something is coming |\n| beyond, for `pendingMinMs` at least | the pending component (a spinner, a skeleton) |\n\n`pendingMinMs` is the anti-flicker floor: once the loader appears it stays put,\nso it can't flash for 40ms.\n\n### Changing the pending component\n\nThe default spinner is replaceable globally:\n\n```typescript\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withPendingComponent(MyBrandedSpinner),\n);\n```\n\n…or per route, which is where it gets interesting — a skeleton shaped like the\npage it is standing in for reads far better than a spinner:\n\n```typescript\n{\n path: 'tasks',\n ...loadCraftComponent(/* … */),\n pendingComponent: () => import('./tasks/tasks-skeleton'),\n stayMs: 150, // this route is slower: get to the skeleton sooner\n blankMs: 0, // and skip the blank phase entirely\n}\n```\n\nRoute-level values override the global ones, so you tune only the routes that\nneed it.\n\n::: tip See it running\nThe `slow-page` demo exists for exactly this: two deliberately slow steps (~1.5s\neach) so you can watch the stay → blank → loader phases play out. The first\nvisit is slow, a revisit is instant thanks to the query cache, and a \"clear\ncache\" button replays it. Source:\n[slow-page.routes.ts](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/routes/slow-page/slow-page.routes.ts).\n\nFull details — the phase diagram, per-route overrides, view transitions and the\nDI check on skeletons — are on\n[Non-blocking navigation](/guide/routing/pending-ui).\n:::\n\n## Let the CLI write it\n\nHand-writing these pieces gets old. The CLI does it for you and the output stays\nordinary, editable TypeScript:\n\n```shell\nnpx craft route add /tasks --create-component tasks/tasks\n```\n\nIt picks the right collection, creates a lazy routes file per feature, adds the\nloader, the check block and the registry entry, then runs ESLint and `tsc`. Use\n`--dry-run` first.\n\n## What you gained\n\nRouting where a forgotten provider, a misspelled input, an unhandled exception or\na route pointing at nothing stops the build instead of reaching production — and\narchitecture tests keep those proofs from quietly disappearing.\n\n::: details The parts you'll want later\nRoute-scoped providers, guards as bare generators and centralised exception\nhandling all live under [Routing](/guide/routing/setup). Splitting a growing collection across lazy\nchild files is [Scaling routes](/guide/routing/scaling). Architecture tests that keep the DI proofs\narmed are [Architecture rules](/guide/testing/architecture).\n:::\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 8. Build a form](/learn/08-forms)\n\n[10. Test what you wrote →](/learn/10-testing)\n\n</div>\n"
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
"path": "/learn/10-testing",
|
|
599
|
+
"title": "10. Test what you wrote",
|
|
600
|
+
"body": "# 10. Test what you wrote\n\n**Goal:** test `TaskList` and the `Tasks` component without guessing what to\nmock — the dependency graph tells you.\n\n## The idea\n\nMost test setups let you forget a dependency and find out at runtime. Craft\ninverts it: you pass a **register** covering the whole graph, and the compiler\nrefuses to run the test until every node is accounted for.\n\nEach node is one of four things: `'real'`, its own `provideX(...)`, a mock\nobject, or `'notReached'`.\n\n## Testing a service\n\nHere is the service under test — the one from [step 4](/learn/04-compose), with\nits scope changed to `toProvide` so it has a `provideTaskStats()` to mount in\nthe test:\n\n\n\nIt depends on one thing, `TaskList`, and exposes one thing, `done`. The test\nmirrors that exactly:\n\n\n\n`sut` is the service under test; `mocks` gives you back the mocks you supplied,\nalready typed, so `mocks.TaskList.$self` is assertable.\n\n::: tip Which register entry to use\n`provideX()` for a `toProvide` or `manuallyProvidedAtRoot` service, `'real'` for\na reachable `global` or `function` one, a plain object to mock it, and\n`'notReached'` for a branch this test never touches.\n:::\n\n`$self` is the service's own returned value — the ref itself, as opposed to a\nproperty hanging off it.\n\n## Why the register is small\n\nBecause of step 4. `TaskStats` yielded only what it needed, so the register only\nasks for that. Had it yielded the whole `TaskApi`, the register would demand\n`TaskApi` too. **Precise yields make short tests** — that's the payoff for the\n`yield*` discipline.\n\n## Testing a component\n\nHere is the component under test, from steps 2 and 3 — a factory that yields\n`TaskList`, and a template that renders it:\n\n\n\nThose two halves are tested **independently**: the factory produces a context\nwithout touching the DOM, and the template renders a context without running the\nfactory.\n\nThe logic test runs the factory only — no DOM:\n\n\n\nThe template test does the opposite — it renders with a context you hand it, and\nnever runs the factory:\n\n\n\nThat separation is why component tests stay fast: you only pay for the DOM when\nthe DOM is what you're asserting on.\n\n## Finding elements\n\nTemplate tests expose `locator(tag, criteria)` rather than raw CSS selectors:\n\n```typescript\nconst removeButton = test.locator('button', { 'data-testid': 'remove' });\nremoveButton?.click();\n```\n\n## Proving it at the type level\n\nSome of what craft guarantees isn't observable at runtime at all — it's in the\ntypes. Those get their own kind of test, resolved by the compiler with no\n`TestBed`, no DOM and no factory:\n\n```typescript\ntype TasksTemplateTest = SetupTestComponentTemplate<typeof Tasks, [typeof TaskRow]>;\n```\n\nThe resolver walks elements, directives, `each`, `defer` and child components,\nand a child missing from the tuple becomes a type diagnostic. Companion\nassertions — `TemplateHasElement`, `TemplateHasElementWithProps`,\n`TemplateHasYieldableEvent`, `TemplateRendersStateWhen` — check that the template\nreally renders what you think, including event argument types.\n\nThis is how you pin down a template contract that a runtime test would only\ncatch by accident. Full reference:\n[Type-level tests](/guide/testing/type-level).\n\n## Tests that stay close to reality\n\nMocking everything makes tests that pass while the app is broken. `boundaryOnly`\nkeeps the real graph and lets you replace only what actually touches the outside\nworld — the services marked `browserBoundary: true` (HTTP, storage, location):\n\n```typescript\nconst { sut } = await setupCraftServiceTestingByRegister(TaskList, register, {\n boundaryOnly: true,\n});\n```\n\nEverything in between runs for real. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Architecture of the whole app\n\nThe register proves one service's graph is complete. Architecture rules prove\ninvariants **across** services: this feature must not depend on that one, this\nHTTP endpoint is owned once, this `craftUnique` storage key appears once.\n\nThey live next to `e2e/`, analyze TypeScript without starting the application, and are\nordinary Vitest assertions on a typed graph. Look a node up, walk its edges,\nassert. A precise rule — HTTP may only be called from a `browserBoundary`\nservice — is an `it()`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\nAnything you can see on the graph is a rule you can write: folder lanes,\nexclusive feature branches, a method that must not both be called and write a\n`source$`. Built-in helpers cover unique `craftUnique` identities, unique HTTP\nverb+URL, pure `craftComputed`, no `depends-on` cycles, `assertPathBoundaries`,\n`noExclusiveLink`, `assertMutationHasReactOn`, `assertPersistedPrimitiveHasUnique`,\n`assertInsertSelectUnique`, `assertCraftEffectNoNetwork`,\n`assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed`, and the route DI\nproofs from [step 9](/learn/09-routing).\n\nThose proofs (`CanRun`, `RouteCheckedDI`) are unused type aliases — omit one\nand the project still compiles. `assertRouteDiProofs` fails the suite unless\nevery routed component and every `app.config` error screen stays hooked to an\narmed mapper. TypeScript still judges injection; the architecture suite judges\nwhether that judgement was invoked.\n\nFull setup: [Architecture rules](/guide/testing/architecture). Why that graph\nis not Nx's project graph: [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx).\nThe demo app already imports the helpers. From the repository root:\n\n```shell\nnpx nx architecture demo\n```\n\n## What you gained\n\nTests whose setup is derived from the real dependency graph, so \"I forgot to\nmock that\" becomes a compile error — and architecture rules on that same graph,\nso the app can be taught its boundaries.\n\n<div style=\"display: flex; justify-content: space-between; margin-top: 2rem\">\n\n[← 9. Wire up routing](/learn/09-routing)\n\n[Where to go next →](/learn/next)\n\n</div>\n"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
"path": "/learn/next",
|
|
604
|
+
"title": "Where to go next",
|
|
605
|
+
"body": "# Where to go next\n\nYou have the whole mental model: **declare with a name, yield what you do not\nown, derive the rest.** Everything below is a variation on it.\n\n## Fill the gaps in what you built\n\n| You have | Next thing worth adding |\n| ----------------------- | ------------------------------------------------------------------------------------ |\n| A query and a mutation | [Persistence](/guide/state/persistence) — storage persistence as an insertion (localStorage by default) |\n| A list | [Collections](/guide/state/collections) — entity storage, selectors, updates |\n| A form | [Validation](/guide/forms/validation) — custom and async validators |\n| Routes | [Route guards](/guide/routing/guards) and [Route providers](/guide/routing/route-providers) |\n| A running app | [Non-blocking navigation](/guide/routing/pending-ui) — pending UI instead of a freeze |\n\n## Concepts worth a dedicated read\n\n- [The mental model](/guide/concepts/mental-model) — the design principles behind\n the API you just used\n- [Exceptions as values](/guide/concepts/exceptions) — declared failures,\n exhaustively handled\n- [Insertions](/guide/concepts/insertions) — writing your own and composing them\n- [Typed insertion pipes](/guide/concepts/insertion-pipes) — readable composition for each primitive\n- [Generators](/guide/concepts/generators) — `craftGen` outside a service\n\n## Teach the app its boundaries\n\nThe graph you just tested is also a map you can constrain. [Architecture\nrules](/guide/testing/architecture) are ordinary Vitest assertions on the\nstatic Craft graph: unique identities, unique HTTP, pure `craftComputed`,\nfolder lanes, exclusive feature branches — and any neighbourhood you can look\nup is a rule you can write. Nx still owns the workspace graph (imports,\naffected, cache); Craft judges the app. [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx) is the split.\n\nSetup is in that guide. The demo suite already runs them:\n\n```shell\nnpx nx architecture demo\n```\n\n## When your app grows\n\n- [Service scopes](/guide/app/service-scopes) — when `function` stops being enough\n- [Scaling routes](/guide/routing/scaling) — splitting collections before\n TypeScript's instantiation ceiling bites\n- [Lazy services](/guide/app/lazy-services) and [App start](/guide/app/app-start)\n- [Observability](/guide/advanced/observability) — logging and tracing that\n follow the dependency graph\n\n## Reference\n\nLooking for one symbol? The [API index](/reference/) lists every export with a\none-line description and a link.\n\n## See it running\n\n[Examples](/resources/examples) points at the demo application, which exercises\nmost of the above end to end.\n\nImporting Craft into an app that an agent will edit? Point it at\n[coding agents](/resources/ai-agents) — `llms.txt`, the `@craft-ts/mcp` server,\nand the Agent Skills.\n\n<div style=\"margin-top: 2rem\">\n\n[← 10. Test what you wrote](/learn/10-testing)\n\n</div>\n"
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
"path": "/README",
|
|
609
|
+
"title": "@craft-ts Documentation",
|
|
610
|
+
"body": "# @craft-ts Documentation\n\nThis is the VitePress documentation site for the `@craft-ts` packages.\n\nThe current beta requires Node.js 20.19+ (or 22.12+).\n\n## Development\n\nStart the development server:\n\n```bash\nnx dev docs\n# or\nnpm run docs:dev\n```\n\nThe documentation site will be available at `http://localhost:5173`\n\n## Build\n\nBuild the documentation site:\n\n```bash\nnx build docs\n```\n\nThe built site will be in `apps/docs/.vitepress/dist/`\n\n## Structure\n\n```\napps/docs/\n├── .vitepress/\n│ ├── config.mts # VitePress configuration\n│ └── theme/ # Custom theme\n├── index.md # Homepage and package overview\n├── learn/ # Guided tutorial\n├── guide/ # Task-oriented documentation\n├── reference/ # Public API index\n└── resources/ # Examples, migration, roadmap and press kit\n```\n\n## Adding Content\n\n1. Create or edit markdown files in the appropriate directory\n2. The sidebar is configured in `.vitepress/config.mts`\n3. Add links to new pages in the sidebar configuration\n4. Each page should include the import statement for the feature it documents\n\n## Assets\n\nAdd images, logos, and other assets to `public/assets/`\n\n- `craft-ts-logo.png` - Main logo\n- `favicon.png` - Site favicon\n"
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
"path": "/reference",
|
|
614
|
+
"title": "API index",
|
|
615
|
+
"body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| -------------- | -------------------------------------------------------- | ------------------------------------------ |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldExceptionBlock.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `ValidateCascadeRoutesFile`, `CanRun` | Compile-time DI check for a routes file | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI` | Per-route `O(1)` variant of the check | [Scaling routes](/guide/routing/scaling) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingBlock({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
"path": "/resources/ai-agents",
|
|
619
|
+
"title": "Coding agents",
|
|
620
|
+
"description": "Point Cursor, Claude, Copilot, and other coding agents at CraftTS docs, MCP tools, and Agent Skills after you import @craft-ts/core.",
|
|
621
|
+
"body": "# Coding agents\n\nCraftTS has a deliberate vocabulary. After you import `@craft-ts/core`, give\nthe agent three layered entry points — `llms.txt`, the MCP server, and Agent\nSkills — so it can use the documented primitives and conventions.\n\n| Layer | What it is | When the agent uses it |\n| --- | --- | --- |\n| **LLM files** | [`/llms.txt`](https://craft-ts.github.io/craft/llms.txt), [`/llms-full.txt`](https://craft-ts.github.io/craft/llms-full.txt), and a `.md` sibling for every docs page | Discovery on the internet, no install |\n| **MCP server** | [`@craft-ts/mcp`](https://www.npmjs.com/package/@craft-ts/mcp) — `get_best_practices`, `search_documentation`, `find_examples`, skills | Live lookup in Cursor, Claude Code, VS Code, Copilot |\n| **Agent Skills** | `skills/` inside `@craft-ts/mcp`, plus an [Agent Plugin](https://agent-plugins.org/) manifest | Multi-step workflows (architecture tests, routes, spec → primitives, migration) |\n| **Live page MCP** | Local `@craft-ts/function-registry-mcp` tool `page` — fill, click, and inspect the development tab already open | Dev only, on the running app. Not shipped in `@craft-ts/mcp`. See [Live page MCP](/guide/ai/dev-page) |\n\nDo not scrape the HTML docs. Start from `llms.txt` or the MCP tools.\n\n## 1. LLM files\n\nThese follow the [llms.txt](https://llmstxt.org/) spec and are generated from\nthis VitePress site at build time.\n\n- Index (curated links): https://craft-ts.github.io/craft/llms.txt\n- Concatenated docs: https://craft-ts.github.io/craft/llms-full.txt\n- One page, as markdown: append `.md` to any docs URL, for example\n [local state](https://craft-ts.github.io/craft/guide/state/local-state.md)\n\nPaste this into an `AGENTS.md` (or `CLAUDE.md`) at the root of the app that\nimports Craft:\n\n```md\n# CraftTS\n\nThis application uses `@craft-ts/core`.\n\n- Docs index: https://craft-ts.github.io/craft/llms.txt\n- MCP: `npx -y @craft-ts/mcp@beta` (`get_best_practices`, `search_documentation`)\n- Skills: `node_modules/@craft-ts/mcp/skills`\n\nyield* every Craft reader. Keep authored code within Craft's primitives and\nservice model. craftRoutes files need componentDeps and\na per-file DI check. The architecture/ suite is the graph contract: scaffold\nat bootstrap, run it during a feature. Do not add an architecture rule for\nthe feature.\n```\n\nThe same snippet is returned by the MCP tool `get_best_practices` (field\n`agentsMd`) and lives in the package as `content/agents.md`.\n\n## 2. MCP server\n\n```bash\nnpm install -D @craft-ts/mcp@beta\n```\n\nAdd a project `.mcp.json` (Cursor, Claude Code, and VS Code all understand it):\n\n```json\n{\n \"mcpServers\": {\n \"craft-ts\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@craft-ts/mcp@beta\"]\n }\n }\n}\n```\n\nClaude Code, from the app directory:\n\n```shell\nclaude mcp add craft-ts -- npx -y @craft-ts/mcp@beta\n```\n\n### Tools\n\n| Tool | Use it to |\n| --- | --- |\n| `get_best_practices` | Load the coding-agent guide and the `AGENTS.md` snippet |\n| `search_documentation` | Find a Guide / Learn / Reference page by API or task |\n| `get_documentation_page` | Read one page as markdown (`/guide/state/local-state`) |\n| `find_examples` | Find Learn + demo examples |\n| `list_skills` / `get_skill` | Load a workflow skill and its `references/*.md` |\n| `get_llms_txt` | Get the public `llms.txt` URLs and the bundled path index |\n\nThe server is **read-only**. It searches documentation bundled at publish time,\nso it works offline. It is not the runtime registry MCP used to mutate a live\ndemo tab, and it does not expose the `page` tool. Driving the open development\ntab is [Live page MCP](/guide/ai/dev-page) (dev only, function-registry MCP).\n\n## 3. Agent Skills\n\nSkills follow the [Agent Skills](https://agentskills.io/specification) layout\n(`SKILL.md` + optional `references/`). The package is also an Agent Plugin\n(`plugin.json` + `mcp.json` + `skills/`).\n\n| Skill | Trigger |\n| --- | --- |\n| `craft-ts` | Any authored Craft code |\n| `craft-ts-architecture-tests` | Scaffold or run `architecture/`, or freeze a graph smell |\n| `translate-spec-to-craft-ts` | Spec / CRUD / filters / forms → primitives |\n| `craft-ts-routes` | `craftRoutes`, `componentDeps`, `TS2589` |\n| `craft-ts-service-migration` | legacy services → `craftService` |\n| `migrate-to-craft-ts` | `craft-migrate` then manual diagnostics |\n\nThe [architecture suite](/guide/testing/architecture) is the app's graph\ncontract (unique HTTP, unique identities, armed route DI proofs, folder\nlanes). Scaffold it at app start or at the end of `craft-migrate`. During a\nfeature, run the suite that already exists. Do not add an architecture rule for the feature.\nAdd a new `it()` only when a bad pattern is spotted, so it cannot recur. If\n`architecture/` is missing mid-feature, offer the scaffold; do not impose it.\n\nPoint the agent at `node_modules/@craft-ts/mcp/skills`, or let it call\n`get_skill`. Cursor can also install a skill from that folder.\n\n## Verify the agent can see Craft\n\nAsk it to add a `state` counter, a paged `query`, or a `craftRoutes` file.\nIt should `yield*` readers, compose insertions with `craftPipe`, and put a DI\ncheck in the routes file. If the app already has `architecture/`, it should\nrun that suite rather than invent a new rule. If it emits legacy runtime APIs\nor a plain routes array, the MCP server or `AGENTS.md`\nsnippet is not in context.\n\n## See also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [The mental model](/guide/concepts/mental-model)\n- [Architecture rules](/guide/testing/architecture)\n- [CLI automation](/guide/routing/automation)\n- [Migration](/resources/migration)\n"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"path": "/resources/backlog",
|
|
625
|
+
"title": "/resources/backlog",
|
|
626
|
+
"body": "## Backlog\n\n- [ ] For query/mutation/AsyncProcess insertions, expose a set and state similar to other primitive states that will simplify creating reusable insertions. (for persister one more property isStable ? To invalidate state while mutating)\n- [ ] Improve storage persister (better invalidation, handle storing state)\n- [x] Explore to make Source similar to Subject/ReplaySubject\n- [ ] Add support for RxJs source without having an explicit dependency on RxJs and accepts Observable as params for mutation/query/asyncProcess\n- [ ] Clean internal code\n- [ ] Explore explicit type safe error in primitive / use eslint to force handling it (create adapter for OpenApi contract, TS-Rest contract...)\n- [ ] Explore a way to handle selectedIds (that can be used for bulk delete ...), creating a dedicated state, or a dedicated insertion. It will expose all selected, some selected, toggleOne/toggleAll...\n- [ ] Add to-source$ utility to create a source from a DOM event\n- [ ] Propose a state or pattern to handle trees (to explore)\n- [ ] add crossLayerEvent to insertSelect (from bottom to top)\n- [ ] Rename craftException to cException\n- [ ] Create a insertContract similar to a class to implement an interface, also add an helper with a proxy to mock the data ?\n- Explore an explicit way to pass dependencies of primitives (it would be easier for testing)\n\n- forms:\n - Handle async validator calls in parallel\n - login form example, explain how to trigger an exception on submit and debounce errors\n - show an error if the form submit mutation doesn't have the same payload as the form value\n - formRoot can't be used for submission, create an alternative directive?\n"
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"path": "/resources/effect-adoption",
|
|
630
|
+
"title": "Adopting CraftTS progressively",
|
|
631
|
+
"body": "# Adopting CraftTS progressively\n\nYou do not need to rewrite an Effect application before evaluating CraftTS. Keep\nthe domain programs and Layers intact, then introduce Craft at the browser\nboundary one feature at a time.\n\n## Recommended path\n\n### 0. Establish the constraints\n\nBefore changing application code, confirm the\n[compatibility matrix](/resources/effect-compatibility). In particular, check\nthe Effect 4 release-candidate requirement, the Node version and whether SSR is\na hard requirement.\n\n### 1. Keep the domain in Effect\n\nSelect one existing operation with a clear type:\n\n```ts\nEffect<Output, BusinessError, RequiredServices>\n```\n\nKeep its `Context.Service`, `Layer`, tagged errors and tests. The first Craft\nchange should be an adapter, not a rewrite of the business logic.\n\n### 2. Add the Craft boundary\n\nInstall `@craft-ts/effect`, install the bridge once at bootstrap, and expose the\noperation through `queryEffect`, `mutationEffect` or `asyncProcessEffect`.\n\nThe component should call the domain operation. It should not resolve the\nrepository, call `Effect.runPromise`, start a fiber from a click handler or\nduplicate the domain state in a Craft `state`.\n\n### 3. Pilot one read-only feature\n\nStart with a page that has:\n\n- one query;\n- one application or route Layer;\n- one loading state;\n- one typed business error;\n- one technical error path;\n- one executable test.\n\nThis exposes the real cost of the Craft UI model without mixing in forms,\noptimistic updates or server-function transport.\n\n### 4. Add writes and forms\n\nOnce the read path is stable, add `mutationEffect`, then connect it to Craft\nforms and `insertReactOnMutation`. Keep validation responsibilities explicit:\n\n- Effect Schema or `methodSchema` validates a boundary payload;\n- Craft owns field state, validity and interaction;\n- Effect typed errors represent business rejection;\n- defects remain technical failures.\n\n### 5. Introduce route and feature scopes\n\nMove a Layer to the narrowest scope that owns it. Add the compile-time Effect\nrequirements proof for the route, and keep route providers in a named tuple so\nthe type checker can inspect them.\n\nDo this after the first feature works. The proof is valuable, but introducing\nit before the boundary is understood makes the first experiment look more\ncomplex than it is.\n\n### 6. Evaluate server functions separately\n\nTreat the current server-function integration as a separate experiment. Its\ntransport, file conventions, middleware API and deployment integration are not\nfinal. Never treat a client Layer as an authentication or authorization\nboundary; the server must verify claims again.\n\n## What can stay and what changes?\n\n| Existing Effect application asset | During a Craft pilot |\n| --- | --- |\n| Domain types and business operations | Keep |\n| Tagged errors and error unions | Keep; map at the Craft boundary |\n| `Context.Service` contracts | Keep |\n| Live and test `Layer`s | Keep; expose through `provideLayer` |\n| Effect unit tests | Keep |\n| Existing UI components and templates | Keep outside the pilot; replace only the selected Craft feature |\n| UI loading, cancellation and rendering state | Move to Craft resources |\n| URL state and form interaction | Model with Craft primitives and forms |\n\n## Go / no-go signals\n\nProceed when the pilot has a clear resource boundary, an executable Layer setup\nand tests that distinguish business errors from defects.\n\nPause and resolve the issue before expanding when:\n\n- the project is still on Effect 3 without an isolation plan;\n- SSR is mandatory but no SSR host has been selected;\n- the team cannot explain which side owns a piece of state;\n- every feature requires a custom bridge or manual subscription;\n- typecheck time or type errors make the feedback loop unacceptable.\n\n"
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"path": "/resources/effect-compatibility",
|
|
635
|
+
"title": "Effect compatibility and maturity",
|
|
636
|
+
"body": "# Effect compatibility and maturity\n\nThis page describes the current repository contract. It is a decision aid for\nteams evaluating CraftTS, not a promise that beta APIs will remain unchanged.\n\n## Compatibility matrix\n\n| Area | Current contract | Status |\n| --- | --- | --- |\n| Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta |\n| Craft components | `@craft-ts/component` on the same Craft version | Beta |\n| Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration |\n| Effect runtime | `effect` `^4.0.0-rc.110` | Effect 4 release candidate |\n| Effect 3 projects | No compatibility contract | Migrate or isolate before adopting |\n| Node.js | 20.19+ or 22.12+ | Required by the current docs |\n| TypeScript | Use the version supported by the selected Craft beta; verify with the project lockfile | Toolchain-sensitive |\n| Browser application | Vite demo and jsdom tests are covered | Experimental but executable |\n| SSR | No product SSR renderer in this release | Not ready |\n| Server functions | Local transport and middleware experiment | Proof of concept |\n| Migration tooling | `craft-migrate` for Craft concepts | No complete Effect-specific migration |\n\nInstall all Craft packages from the same beta channel. `@craft-ts/effect` also\ndeclares `effect` as a peer dependency, so the Effect version is part of the\napplication's compatibility surface.\n\n## Maturity by capability\n\n| Capability | What is covered today | Adoption guidance |\n| --- | --- | --- |\n| Effect domain programs | `Effect`, tagged errors, `Context.Service`, `Layer` | Good candidate for a pilot |\n| Effect-backed reads and writes | `queryEffect`, `mutationEffect`, `computedEffect`, `asyncProcessEffect` | Pilot with real tests and a narrow feature |\n| Layer scoping | application, route, component and primitive providers | Use after the basic boundary is understood |\n| Typed error mapping | `E` becomes Craft exceptions; defects stay technical errors | Suitable for explicit UI error handling |\n| Effect service mocks | `mockEffectService` plus Craft registers | Suitable for focused tests |\n| Static Effect graph | Effect services, operations and Layers are collected | Useful for architecture rules; still evolving |\n| Server functions | `executeEffect`, middleware and local HTTP demo | Keep behind an experimental boundary |\n| SSR and deployment integration | Not shipped as a product contract | Do not make it a prerequisite for adoption |\n\n## How to read this table\n\nThe safest first adoption is browser-side, one feature, with an existing Effect\ndomain and an application Layer provided by Craft. Defer SSR-specific decisions\nand server functions until their contracts are stable.\n\nSee [Adopting CraftTS progressively](/resources/effect-adoption) for a staged\nplan and [the quickstart's verification section](/learn-effect/00-start-here#5-verify-the-boundary)\nfor the executable Effect demo checks.\n"
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"path": "/resources/examples",
|
|
640
|
+
"title": "Examples",
|
|
641
|
+
"body": "# Examples\n\nEvery example below is a real route of the demo application. Each opens in\nStackBlitz on the relevant file, already navigated to the page.\n\nThe demo groups them the way you would meet them: **components** first, then the\n**primitives** on their own, then the same features **behind services**, then\n**routing** and the rest.\n\n::: tip Just want to poke at something?\nThe [Playground](#playground) is a shareable sandbox with a small todo flow —\nthe fastest way to try an idea.\n:::\n\n## Components\n\nFunctional, selectorless components rendered from typed hyperscript.\n\n| Example | What it shows |\n| --- | --- |\n| [Functional Components](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-demo.ts&initialpath=/) | `craftComponent`, inputs and outputs as factory parameters, hyperscript templates |\n| [Reactive Composition](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/component-composition-demo.ts&initialpath=/component-composition) | Composing components and directives with `.pipe(...)` |\n| [Content Projection](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/content-projection-demo.ts&initialpath=/content-projection) | Free DOM content, typed DOM contracts, and logical projection by contract |\n| [Pending Block](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-block-demo.ts&initialpath=/pending-block) | Type-safe async suspension with `settledValue`, `settled(...)` and `pendingBlock` |\n| [Pending Block — Exception](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/component/pending-block-exception-demo.ts&initialpath=/pending-block/exception) | Coordinating pending, reloading and business-exception fallbacks with `pendingBlock` and `catchBlock` |\n\n## Primitives\n\nUsing `state`, `query`, `mutation`, `queryParams` and `asyncProcess` directly,\nwith no service layer.\n\n| Example | What it shows |\n| --- | --- |\n| [Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/query/query.ts&initialpath=/query/1) | `query()` with reactive params, status and caching |\n| [Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/mutation/mutation.ts&initialpath=/mutation/1) | `mutation()` with manual control of modification operations |\n| [List with Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/list-with-pagination/list-with-pagination.ts&initialpath=/list-with-pagination) | Pagination with hand-managed query params and page state |\n| [Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/granular-mutation/granular-mutation.ts&initialpath=/granular-mutation) | Optimistic updates and cache invalidation, done by hand |\n| [Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/full-demo/full-demo.ts&initialpath=/full-demo) | Everything at once, without store or service abstractions |\n| [Login Form](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/forms/login-form.ts&initialpath=/login-form) | `insertForm`, validators, and a typed submit wired to a mutation |\n| [Pixel Art](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art/pixel-art.ts&initialpath=/pixel-art) | `state` + `insertSelect` over a flat array |\n| [Pixel Art Matrix](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/pixel-art-matrix/pixel-art-matrix.ts&initialpath=/pixel-art-matrix) | Nested `insertSelect` and internal `source$` between rows and cells |\n| [Exceptions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exceptions.ts&initialpath=/exceptions) | Business exceptions on `query()`, rendered per code with `matchBlock.exhaustive` |\n| [Exception QueryParams](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/primitives/exceptions/exception-query-params.ts&initialpath=/exception-query-params) | `queryParams` decode failures through `hasException()` and `exceptions().parse` |\n\n## Services\n\nThe same features, packaged behind `craftService`.\n\n| Example | What it shows |\n| --- | --- |\n| [Craft Query](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/query/query.ts&initialpath=/craft/query/1) | A reusable query service with configured storage persistence (localStorage by default) |\n| [Craft Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/mutation/mutation.ts&initialpath=/craft/mutation/1) | Create / update / delete with reactive cache synchronisation |\n| [Craft List Pagination](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/list-with-pagination/list-with-pagination.ts&initialpath=/craft/list-with-pagination) | `queryParams` + `insertPaginationPlaceholderData` in a service |\n| [Craft Granular Mutation](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/granular-mutation/granular-mutation.ts&initialpath=/craft/granular-mutation) | `insertReactOnMutation` updating cached data without a reload |\n| [Craft Full Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/full-demo/full-demo.ts&initialpath=/craft/full-demo) | Queries, mutations, async work, URL state and persistence together |\n| [craftService Counter](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-counter.ts&initialpath=/craft-service/counter) | The smallest possible service — scopes and composition |\n| [craftService User Detail](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/craft-service-user-detail.ts&initialpath=/craft-service/user-detail) | Service inputs, and exposing only part of a dependency |\n| [craftRegisterFor](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft-service/register-for.ts&initialpath=/craft-service/register-for) | A parent driving live children through a typed registry |\n\n## Routing\n\n| Example | What it shows |\n| --- | --- |\n| [Query Params in the route](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/list-with-pagination/qp-list-with-pagination.ts&initialpath=/query-params) | `queryParams` declared on the route rather than in a component |\n| [Guard Demo](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/guard-demo/GuardDemo.ts&initialpath=/guard-demo) | Guards as bare generators, and `handleExceptions` per code |\n| [Slow Page](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/slow-page/slow-page.routes.ts&initialpath=/slow-page) | Non-blocking navigation: the stay → blank → loader phases, and a `craftGen` resolver recovered locally with `catchTag` |\n| [View Transitions](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/routes/view-transitions/view-transitions.routes.ts&initialpath=/view-transitions) | Outlet-driven view transitions surviving the guard/resolve chain, with a per-route skeleton |\n| [Lazy Layout](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/craft/lazy-layout/lazy-layout.routes.ts&initialpath=/craft/lazy-layout/1) | A lazy child collection with its own DI check and a route-provided service |\n\n## Tooling\n\n| Example | What it shows |\n| --- | --- |\n| [Playground](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/playground/playground.ts&initialpath=/playground) | A shareable sandbox: a small todo flow with `craftService`, `query()` and `mutation()` |\n| [Send Context to AI](https://stackblitz.com/github/craft-ts/craft-ts-demo/tree/main/?file=src/app/examples/ia/demo-send-context/demo-send-context.ts&initialpath=/demo-send-context) | Exporting the live dependency graph and app context to an assistant |\n\n## Notes\n\nEach example ships its own `api.service.ts` simulating the network, so every\nroute works standalone.\n\nSource repository:\n[craft-ts-demo](https://github.com/craft-ts/craft-ts-demo).\n"
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
"path": "/resources/migration",
|
|
645
|
+
"title": "Migrating an existing application",
|
|
646
|
+
"body": "# Migrating an existing application\n\n`craft-migrate` runs the CraftTS codemods in a safe, explicit order:\n\n1. primitive migration points\n2. service composition\n3. typed route collections and dependency checks\n4. legacy `component(...)` factories to `craftComponent(name, ...)`\n5. baseline architecture tests\n\nThe migration is intentionally conservative. Deterministic transformations are\nwritten automatically; code requiring a business or lifecycle decision is\nreported as a manual diagnostic.\n\n## Install the migration tool\n\n```shell\nnpm install @craft-ts/core\nnpm install --save-dev @craft-ts/dev-tools@beta\n```\n\nThe migration binaries are available from the `beta` tag. Verify the resolved\nversion before starting:\n\n```shell\nnpm ls @craft-ts/dev-tools\n```\n\nPoint the coding agent at [coding agents](/resources/ai-agents) so it uses\n`craft-migrate` through the `migrate-to-craft-ts` skill. The final step\nscaffolds the [architecture suite](/guide/testing/architecture) as the graph\ncontract. Do not add one architecture rule per migrated feature.\n\nCommit or stash the current application changes before writing a migration.\nThe codemod does not revert unrelated local changes.\n\n## Preview the migration\n\nRun the command from the application workspace:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --dry-run\n```\n\nUse a JSON report when diagnostics need to be reviewed or archived:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --dry-run \\\n --json migration-report.json\n```\n\n## Apply the migration\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\n`--write` runs ESLint fixes on files touched by the primitive and service\nmigrations. Use `--no-eslint` only when linting is managed separately.\n\nThe specialized commands remain available when a migration must be applied or\ndebugged one stage at a time:\n\n```shell\nnpx craft-migrate-primitives --project tsconfig.app.json --root src --write\nnpx craft-migrate-services --project tsconfig.app.json --root src --write\nnpx craft-migrate-routes --project tsconfig.app.json --root src --write\nnpx craft-migrate-components --project tsconfig.app.json --root src --write\nnpx craft-migrate-architecture --project tsconfig.app.json --root src --write\n```\n\nFor a pasted HTML or Web Component snippet, use the standalone template\nconverter:\n\n```shell\nprintf '<section><h2>Hello</h2></section>' | npx craft-migrate-template\n```\n\nThe generated callback can be pasted as the fourth argument of\n`craftComponent(...)`. The interactive [template converter](/guide/components/template-migrator)\nuses the same converter.\n\n## Work remaining after the codemod\n\nSearch the generated report and source code for migration diagnostics. Complete\nthe following before considering the migration done:\n\n- Consume every primitive invocation inside a generator with `yield*`, or use\n `craftUse(...)` at a synchronous boundary.\n- Map synchronous validators to `cRequired`, `cMaxLength`, and the other Craft\n validators.\n- Replace asynchronous validation with `query` and `cAsyncValidate`.\n- Replace form submission workflows with `mutation` and `insertFormSubmit`.\n- Resolve every `CRAFT_IMPLEMENTATION_REQUIRED` companion service.\n- Review service scopes and move `provideX(...)` close to the route or feature\n that owns the instance.\n- Resolve imperative workflow diagnostics instead of only removing comments.\n- Migrate guards, dynamic redirects, nested route collections, inherited route\n providers, and other route diagnostics that could not be inferred safely.\n- Confirm `componentDeps`, route provider names, and file-level DI checks.\n- Review HTTP mutations and subscriptions whose lifecycle semantics could not be\n moved automatically.\n- Add app-specific graph lookups in `architecture.spec.ts`.\n\n## Verify the result\n\nFirst make remaining migration work fail CI:\n\n```shell\nnpx craft-migrate \\\n --project tsconfig.app.json \\\n --root src \\\n --check \\\n --fail-on-manual\n```\n\nThen run the normal project verification:\n\n```shell\nnpx eslint \"src/**/*.ts\"\nnpx tsc --noEmit -p tsconfig.app.json\nnpx vitest run --config vitest.architecture.config.ts\n```\n\nUse the workspace-specific lint, test, and build commands when they differ.\nFinally, exercise forms, navigation, pending/error UI, and write operations in\nthe browser: those lifecycle behaviours cannot be fully established by a\nstructural codemod.\n"
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
"path": "/resources/press-kit",
|
|
650
|
+
"title": "Press Kit",
|
|
651
|
+
"body": "# Press Kit\n\nResources and information about `@craft-ts/core` for articles, presentations,\nand sharing.\n\n## Project description\n\n### Short description\n\n`@craft-ts/core` is a reactive TypeScript toolkit for URL, client, and server\nstate. Its primitives make dependencies explicit and keep application code\nfully type-safe.\n\n### Long description\n\n`@craft-ts/core` brings state, asynchronous work, services, forms, routing, and\ntesting into one composable model. Named generators and typed insertions make\nthe dependency graph visible to both the compiler and development tools. The\nresult is granular reactivity, typed failures, optimistic updates, persistence,\nand predictable loading states without repetitive coordination code.\n\n## Key features\n\n- ✅ **Type-safe** — TypeScript inference minimizes manual declarations\n- ✅ **Composable** — primitives and insertions share one composition model\n- ✅ **Granular** — updates target the readers that actually depend on them\n- ✅ **Declarative** — state, effects, forms, and routes are explicit data\n- ✅ **Observable** — the same graph powers logging, tracing, and diagnostics\n- ✅ **Testable** — services, components, and architecture contracts can be tested independently\n\n## Logo and brand assets\n\n\n\n- [Download logo](/assets/craft-ts-logo.png)\n\n## Installation\n\n```shell\nnpm i @craft-ts/core@beta\n```\n\n## Links\n\n- **GitHub**: [github.com/craft-ts/craft-ts](https://github.com/craft-ts/craft-ts)\n- **Documentation**: [craft-ts.github.io/craft/](https://craft-ts.github.io/craft/)\n- **NPM**: [npmjs.com/package/@craft-ts/core](https://npmjs.com/package/@craft-ts/core)\n\n## Social media\n\n### LinkedIn\n\n```text\nExcited to share @craft-ts/core — a type-safe toolkit for state, services,\nforms, routing, and asynchronous work.\n\nCraft makes dependencies explicit and gives every primitive a predictable\nlifecycle, typed failures, and composable behaviour:\n\n• Reactive local and server state\n• URL state with typed codecs\n• Optimistic mutations\n• Typed forms and validation\n• Explicit service composition\n• Architecture checks and observability\n\nCheck it out: [link]\n\n#TypeScript #WebDevelopment #OpenSource\n```\n\n## License\n\nMIT License — free for personal and commercial use.\n\n## Credits\n\nCreated and maintained by Romain Geffrault.\n\n## Contact\n\n- **Issues**: [GitHub Issues](https://github.com/craft-ts/craft-ts/issues)\n- **Discussions**: [GitHub Discussions](https://github.com/craft-ts/craft-ts/discussions)\n"
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
"path": "/resources/roadmap",
|
|
655
|
+
"title": "Roadmap",
|
|
656
|
+
"body": "# Roadmap\n\n@craft-ts/core is evolving through real-world usage, careful experimentation,\nand feedback from the community. This roadmap describes the areas I am\ncurrently planning to explore; it is intentionally not a promise of fixed\nrelease dates.\n\n## Near-term priorities\n\n### SSR as a Craft host\n\nSSR is a Craft deployment concern: serialize Craft trees to HTML at the runtime\nboundary.\nThat work lives in a later compiler/host plan; this release does not ship a\nproduct SSR renderer.\n\n### Real-world integration and stability\n\nI will continue integrating `@craft-ts/core` into projects so that I can\nexperiment with the different situations and constraints that applications\nencounter in practice. This ongoing use should help uncover edge cases,\nvalidate the API, and move the library towards the most stable version\npossible.\n\nI am also studying improvements that could make the codebase more robust. I\nam open to suggestions, proposals, and discussions about changes that would\nimprove reliability, maintainability, or the developer experience.\n\n## Type-safe design systems\n\nAnother area I am actively exploring is how to create a design system that is\nas type-safe as possible. The aim is to make design-system APIs expressive and\nsafe to use while preserving a good development experience.\n\n- Improve the type-level techniques used by the library so that they are more\n efficient. In particular, I want to reduce type compilation time and make\n the feedback loop faster for developers.\n\nOne current challenge is TypeScript's memory limitation. A very ambitious\ntype-level design can place a significant load on the TypeScript compiler, so\nthis constraint has to be considered alongside the benefits of stronger\ninference.\n\nIf you have ideas for addressing this problem, I would be very happy to hear\nthem. Please feel free to share your opinions and suggestions. I am willing\nto introduce utilities or adaptations where necessary to make promising\napproaches compatible with the library and practical to use.\n\n## Tooling for understanding changes\n\nI also plan to create a precise dependency graph and a tool that can compare\ntwo branches. The goal is to make the changes introduced by artificial\nintelligence easier to inspect and understand, by providing a clearer view of\nthe affected dependencies and the differences between two versions of a\ncodebase.\n\nI may also extend the dependency graph to represent complete paths through the\ngraph, making it possible to follow how a change propagates across the\ncodebase. This could provide a foundation for adding architecture tests and\narchitecture constraints directly to the same tooling, so that intended\ndependencies and boundaries can be checked automatically.\n\nI am also considering building DevTools for `@craft-ts/core`, although I am\nnot yet certain how valuable a traditional DevTools experience would be for\nthe library. If there are features or workflows you would find useful in this\narea, please feel free to tell me about them.\n\nSeveral of my current ideas are more AI-first: tools designed to help an AI\nagent debug an application through WebMCP and observability, for example by\nmaking runtime state, dependency relationships, and application events easier\nto inspect and reason about. Feedback will help determine whether these ideas\nshould become part of a DevTools experience or evolve as separate tools.\n\n## Exploring a typed RxJS-like library\n\nI am also studying the possibility of creating a typed RxJS-like library built\naround the principles of `@craft-ts/core`. The goal would be to preserve the\nadvantages of the existing RxJS ecosystem while providing stronger typing,\ntreating errors as exceptions, and integrating observability natively with\nCraftTS. It would also include dependency tracking, making reactive\nrelationships explicit and inspectable.\n\n## Longer-term exploration: type-safe server functions\n\nFurther ahead, I am considering a server-function system built around the\nsame principles. The idea is to allow dependency injection in server\nfunctions while keeping it fully type-safe.\n\nSuch a system could also allow the server function to depend on data supplied\nby the front end. That data would be passed automatically and checked in a\ntype-safe way, so the contract between the client and the server remains\nexplicit and reliable from end to end.\n\nThis is an early exploration rather than a committed API. Feedback about the\ndesign, the use cases, and the trade-offs would be especially valuable as the\nidea develops.\n\n## Share your ideas\n\nThe roadmap will evolve as these experiments produce results. If you have\nfeedback, use cases, or ideas for making `@craft-ts/core` more robust and\ntype-safe, please share them through [GitHub Discussions](https://github.com/craft-ts/craft-ts/discussions)\nor [GitHub Issues](https://github.com/craft-ts/craft-ts/issues).\n"
|
|
657
|
+
}
|
|
658
|
+
]
|