graftjs 0.1.0 → 0.4.0

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 CHANGED
@@ -20,7 +20,7 @@ Compose React components by wiring named parameters together.
20
20
  No prop drilling. No Context. No useState. No useEffect. No manual subscriptions.
21
21
 
22
22
  ```
23
- npm install graft
23
+ npm install graftjs
24
24
  ```
25
25
 
26
26
  ## Why
@@ -43,7 +43,7 @@ A **source** is a component with no inputs that pushes values over time — a We
43
43
 
44
44
  **`instantiate`** creates an isolated copy of a subgraph. It takes a template — a function that builds and returns a component — and returns a new component. Each subscription gets its own fresh instance, so any `state()` or `source()` calls inside the template produce independent cells. This is how you get local state.
45
45
 
46
- **`compose({ into, from, key })`** wires `from`'s output into `into`'s input named `key`. Returns a new component whose inputs are `into`'s remaining inputs plus `from`'s inputs.
46
+ **`compose({ into, from, key })`** wires `from`'s output into `into`'s input named `key`. **`compose({ into, from: { k1: A, k2: B } })`** wires multiple inputs at once. Both return a new component whose inputs are the remaining unsatisfied ones.
47
47
 
48
48
  When you're done composing, **`toReact`** converts the result into a regular `React.FC` (requires the output to be `ReactElement`).
49
49
 
@@ -130,14 +130,12 @@ const PriceCard = component({
130
130
 
131
131
  // --- Wiring ---
132
132
 
133
- // PriceFeed → FormatPrice → PriceCard.displayPrice
134
133
  const LivePrice = compose({ into: FormatPrice, from: PriceFeed, key: "price" });
135
- const WithPrice = compose({ into: PriceCard, from: LivePrice, key: "displayPrice" });
136
-
137
- // CoinName (async) → Header → PriceCard.header
138
134
  const NamedHeader = compose({ into: Header, from: CoinName, key: "name" });
135
+
136
+ // Wire both into PriceCard at once
139
137
  const App = toReact(
140
- compose({ into: WithPrice, from: NamedHeader, key: "header" }),
138
+ compose({ into: PriceCard, from: { displayPrice: LivePrice, header: NamedHeader } }),
141
139
  );
142
140
 
143
141
  // One prop left — everything else is wired internally.
@@ -187,18 +185,24 @@ const FetchAge = component({
187
185
 
188
186
  The input schema is the source of truth for both TypeScript types and runtime validation. The output schema declares the type of value the component produces. Use `View` for components that return JSX.
189
187
 
190
- ### `compose({ into, from, key })`
188
+ ### `compose({ into, from, key })` / `compose({ into, from: { ... } })`
191
189
 
192
190
  Wire `from`'s output into `into`'s input named `key`. Returns a new component.
193
191
 
194
192
  ```tsx
195
193
  import { compose } from "graft";
196
194
 
197
- // UserCard needs { name, email, age }
198
- // FetchAge needs { userId } and produces a number
199
- // After composing on "age":
200
- // → new component needs { name, email, userId }
195
+ // Single-wire: one output into one input
201
196
  const UserCardWithAge = compose({ into: UserCard, from: FetchAge, key: "age" });
197
+ // UserCard needs { name, email, age }, FetchAge needs { userId }
198
+ // → new component needs { name, email, userId }
199
+
200
+ // Multi-wire: wire several inputs at once
201
+ const FullCard = compose({
202
+ into: UserCard,
203
+ from: { age: FetchAge, email: FetchEmail },
204
+ });
205
+ // → new component needs { name, userId }
202
206
  ```
203
207
 
204
208
  The key insight: `"age"` disappears from the inputs because it's now satisfied internally. `"userId"` appears because FetchAge needs it and nobody provides it yet.
@@ -318,9 +322,8 @@ const Form = component({
318
322
  ),
319
323
  });
320
324
 
321
- const WithName = compose({ into: Form, from: NameField, key: "name" });
322
325
  const App = toReact(
323
- compose({ into: WithName, from: EmailField, key: "email" }),
326
+ compose({ into: Form, from: { name: NameField, email: EmailField } }),
324
327
  );
325
328
 
326
329
  // Each field maintains its own text value independently.
@@ -449,20 +452,20 @@ const ExtractEmail = component({
449
452
 
450
453
  // --- Wiring ---
451
454
 
452
- const WithPostCount = compose({ into: ProfilePage, from: PostCount, key: "postCount" });
453
- // Inputs: { name, email, avatarUrl, userId }
454
-
455
- const WithAvatar = compose({ into: WithPostCount, from: Avatar, key: "avatarUrl" });
456
- // Inputs: { name, userId, email }
457
-
458
- const WithName = compose({ into: WithAvatar, from: ExtractName, key: "name" });
455
+ const WithUserData = compose({
456
+ into: ProfilePage,
457
+ from: {
458
+ postCount: PostCount,
459
+ avatarUrl: Avatar,
460
+ name: ExtractName,
461
+ email: ExtractEmail,
462
+ },
463
+ });
459
464
  // Inputs: { userId, email, userInfo }
460
465
 
461
- const WithEmail = compose({ into: WithName, from: ExtractEmail, key: "email" });
462
- // Inputs: { userId, userInfo }
463
-
464
- const WithUserInfo = compose({ into: WithEmail, from: UserInfo, key: "userInfo" });
465
- // Inputs: { userId }
466
+ const WithUserInfo = compose({ into: WithUserData, from: UserInfo, key: "userInfo" });
467
+ // Inputs: { userId, email }
468
+ // (email is needed by Avatar directly and by UserInfo → ExtractEmail)
466
469
 
467
470
  const ProfilePageReact = toReact(WithUserInfo);
468
471
 
@@ -552,6 +555,14 @@ This means:
552
555
 
553
556
  Reference equality is intentional. Two different objects with identical content (`{ x: 1 } !== { x: 1 }`) are *not* deduped — this matches React's behavior and avoids the cost and surprises of deep comparison.
554
557
 
558
+ ## No unnecessary re-renders
559
+
560
+ In React, when a parent re-renders, all its children re-render too — unless you manually opt out with `memo`, `useMemo`, `useCallback`, etc. A state change at the top of the tree cascades through every child, even ones that don't use that state. Preventing unnecessary renders is a constant tax on the developer.
561
+
562
+ Graft doesn't have this problem. A value change only propagates along the explicit `compose()` edges. If source A feeds into component X, and source B feeds into component Y, then A changing has zero effect on Y — there's no shared tree to cascade through. Each component only re-runs when its actual inputs change.
563
+
564
+ This isn't an optimization. It's a structural property of the architecture — graft simply doesn't have a mechanism to produce unnecessary re-renders in the first place.
565
+
555
566
  ## How it works
556
567
 
557
568
  Graft is a runtime library, not a compiler plugin. `compose()` is a regular function call that:
@@ -566,7 +577,7 @@ The type-level generics ensure TypeScript knows exactly what inputs the composed
566
577
  ## Install
567
578
 
568
579
  ```
569
- npm install graft
580
+ npm install graftjs
570
581
  ```
571
582
 
572
583
  Requires React 18+ as a peer dependency. Uses [zod v4](https://zod.dev) (`zod/v4` import) for schemas.
package/dist/compose.d.ts CHANGED
@@ -2,15 +2,15 @@ import React, { type ReactElement } from "react";
2
2
  import { z } from "zod/v4";
3
3
  import { type GraftComponent } from "./types.js";
4
4
  /**
5
- * compose({ into, from, key }):
6
- * - into: a component with inputs SA that produces OA
7
- * - from: a component with inputs SB that produces OB
8
- * - key: an input name of `into` whose type matches OB
9
- * - Result: a component whose inputs are SA minus key, plus SB,
10
- * and whose output is OA
5
+ * compose({ into, from, key }) — single-wire form:
6
+ * Wires `from`'s output into `into`'s input named `key`.
11
7
  *
12
- * If either `from` or `into` is async, the composed run is async.
13
- * `from`'s output feeds into `into[key]`, remaining params bubble up.
8
+ * compose({ into, from: { k1: A, k2: B, ... } }) multi-wire form:
9
+ * Wires multiple components into `into` at once. Each key in `from`
10
+ * names an input of `into`, and its value is the component that provides it.
11
+ * Equivalent to chaining single-wire compose calls.
12
+ *
13
+ * In both forms, unsatisfied inputs bubble up as the composed component's props.
14
14
  *
15
15
  * subscribe() propagates reactivity: subscribes to `from`, and whenever
16
16
  * `from` emits, re-subscribes to `into` with the new value, forwarding
@@ -20,6 +20,10 @@ import { type GraftComponent } from "./types.js";
20
20
  * it passes the sentinel directly to the outer callback without calling
21
21
  * `into`'s run/subscribe.
22
22
  */
23
+ export declare function compose<SA extends z.ZodObject<z.ZodRawShape>, OA>({ into, from }: {
24
+ into: GraftComponent<SA, OA>;
25
+ from: Record<string, GraftComponent<z.ZodObject<z.ZodRawShape>, unknown>>;
26
+ }): GraftComponent<z.ZodObject<z.ZodRawShape>, OA>;
23
27
  export declare function compose<SA extends z.ZodObject<z.ZodRawShape>, SB extends z.ZodObject<z.ZodRawShape>, K extends string & keyof z.infer<SA>, OA, OB>({ into, from, key }: {
24
28
  into: GraftComponent<SA, OA>;
25
29
  from: GraftComponent<SB, OB>;
package/dist/compose.js CHANGED
@@ -28,26 +28,23 @@ function splitProps(parsed, into, from, key) {
28
28
  };
29
29
  return { fromInput, buildIntoInput };
30
30
  }
31
- /**
32
- * compose({ into, from, key }):
33
- * - into: a component with inputs SA that produces OA
34
- * - from: a component with inputs SB that produces OB
35
- * - key: an input name of `into` whose type matches OB
36
- * - Result: a component whose inputs are SA minus key, plus SB,
37
- * and whose output is OA
38
- *
39
- * If either `from` or `into` is async, the composed run is async.
40
- * `from`'s output feeds into `into[key]`, remaining params bubble up.
41
- *
42
- * subscribe() propagates reactivity: subscribes to `from`, and whenever
43
- * `from` emits, re-subscribes to `into` with the new value, forwarding
44
- * `into`'s emissions to the outer callback.
45
- *
46
- * If `from` emits GraftLoading or GraftError, compose short-circuits —
47
- * it passes the sentinel directly to the outer callback without calling
48
- * `into`'s run/subscribe.
49
- */
31
+ // Implementation
50
32
  export function compose({ into, from, key }) {
33
+ // Multi-wire form: from is a Record<string, GraftComponent>
34
+ if (!key && typeof from === "object" && from !== null && from._tag !== "graft-component") {
35
+ const entries = Object.entries(from);
36
+ if (entries.length === 0)
37
+ return into;
38
+ let result = into;
39
+ for (const [k, provider] of entries) {
40
+ result = composeSingle(result, provider, k);
41
+ }
42
+ return result;
43
+ }
44
+ // Single-wire form
45
+ return composeSingle(into, from, key);
46
+ }
47
+ function composeSingle(into, from, key) {
51
48
  // Build the new schema: into's shape minus key, plus from's shape
52
49
  const intoShape = { ...into.schema.shape };
53
50
  delete intoShape[key];
@@ -58,7 +55,8 @@ export function compose({ into, from, key }) {
58
55
  const { fromInput, buildIntoInput } = splitProps(parsed, into, from, key);
59
56
  const fromOutput = from.run(fromInput);
60
57
  const runInto = (resolvedFromOutput) => {
61
- return into.run(buildIntoInput(resolvedFromOutput));
58
+ const validated = from.outputSchema.parse(resolvedFromOutput);
59
+ return into.run(buildIntoInput(validated));
62
60
  };
63
61
  if (isPromise(fromOutput)) {
64
62
  return fromOutput.then((v) => runInto(v));
@@ -90,8 +88,10 @@ export function compose({ into, from, key }) {
90
88
  cb(fromValue);
91
89
  return;
92
90
  }
93
- // Subscribe to into with the new from value
94
- intoCleanup = into.subscribe(buildIntoInput(fromValue), (intoValue) => {
91
+ // Validate from's output at the boundary
92
+ const validated = from.outputSchema.parse(fromValue);
93
+ // Subscribe to into with the validated from value
94
+ intoCleanup = into.subscribe(buildIntoInput(validated), (intoValue) => {
95
95
  if (!disposed)
96
96
  cb(intoValue);
97
97
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graftjs",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Compose React components by wiring named parameters — type-safe, no prop drilling, no hooks",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,7 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "build": "tsc",
19
- "test": "npx tsx --test tests/*.test.tsx",
19
+ "test": "npx tsx --test --test-force-exit tests/*.test.tsx",
20
20
  "prepublishOnly": "tsc"
21
21
  },
22
22
  "repository": {