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 +38 -27
- package/dist/compose.d.ts +12 -8
- package/dist/compose.js +22 -22
- package/package.json +2 -2
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
|
|
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`.
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
462
|
-
// Inputs: { userId,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
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.
|
|
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": {
|