@granularjs/core 1.0.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 +576 -0
- package/dist/granular.min.js +2 -0
- package/dist/granular.min.js.map +7 -0
- package/package.json +54 -0
- package/src/core/bootstrap.js +63 -0
- package/src/core/collections/observable-array.js +204 -0
- package/src/core/component/function-component.js +82 -0
- package/src/core/context.js +172 -0
- package/src/core/dom/dom.js +25 -0
- package/src/core/dom/element.js +725 -0
- package/src/core/dom/error-boundary.js +111 -0
- package/src/core/dom/input-format.js +82 -0
- package/src/core/dom/list.js +185 -0
- package/src/core/dom/portal.js +57 -0
- package/src/core/dom/tags.js +182 -0
- package/src/core/dom/virtual-list.js +242 -0
- package/src/core/dom/when.js +138 -0
- package/src/core/events/event-hub.js +97 -0
- package/src/core/forms/form.js +127 -0
- package/src/core/internal/symbols.js +5 -0
- package/src/core/network/websocket.js +165 -0
- package/src/core/query/query-client.js +529 -0
- package/src/core/reactivity/after-flush.js +20 -0
- package/src/core/reactivity/computed.js +51 -0
- package/src/core/reactivity/concat.js +89 -0
- package/src/core/reactivity/dirty-host.js +162 -0
- package/src/core/reactivity/observe.js +421 -0
- package/src/core/reactivity/persist.js +180 -0
- package/src/core/reactivity/resolve.js +8 -0
- package/src/core/reactivity/signal.js +97 -0
- package/src/core/reactivity/state.js +294 -0
- package/src/core/renderable/render-string.js +51 -0
- package/src/core/renderable/renderable.js +21 -0
- package/src/core/renderable/renderer.js +66 -0
- package/src/core/router/router.js +865 -0
- package/src/core/runtime.js +28 -0
- package/src/index.js +42 -0
- package/types/core/bootstrap.d.ts +11 -0
- package/types/core/collections/observable-array.d.ts +25 -0
- package/types/core/component/function-component.d.ts +14 -0
- package/types/core/context.d.ts +29 -0
- package/types/core/dom/dom.d.ts +13 -0
- package/types/core/dom/element.d.ts +10 -0
- package/types/core/dom/error-boundary.d.ts +8 -0
- package/types/core/dom/input-format.d.ts +6 -0
- package/types/core/dom/list.d.ts +8 -0
- package/types/core/dom/portal.d.ts +8 -0
- package/types/core/dom/tags.d.ts +114 -0
- package/types/core/dom/virtual-list.d.ts +8 -0
- package/types/core/dom/when.d.ts +13 -0
- package/types/core/events/event-hub.d.ts +48 -0
- package/types/core/forms/form.d.ts +9 -0
- package/types/core/internal/symbols.d.ts +4 -0
- package/types/core/network/websocket.d.ts +18 -0
- package/types/core/query/query-client.d.ts +73 -0
- package/types/core/reactivity/after-flush.d.ts +4 -0
- package/types/core/reactivity/computed.d.ts +1 -0
- package/types/core/reactivity/concat.d.ts +1 -0
- package/types/core/reactivity/dirty-host.d.ts +42 -0
- package/types/core/reactivity/observe.d.ts +10 -0
- package/types/core/reactivity/persist.d.ts +1 -0
- package/types/core/reactivity/resolve.d.ts +1 -0
- package/types/core/reactivity/signal.d.ts +11 -0
- package/types/core/reactivity/state.d.ts +14 -0
- package/types/core/renderable/render-string.d.ts +2 -0
- package/types/core/renderable/renderable.d.ts +15 -0
- package/types/core/renderable/renderer.d.ts +38 -0
- package/types/core/router/router.d.ts +57 -0
- package/types/core/runtime.d.ts +26 -0
- package/types/index.d.ts +2 -0
package/README.md
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# Granular Framework (WIP)
|
|
2
|
+
|
|
3
|
+
Granular is a JS-first frontend framework built for performance, clarity, and real control.
|
|
4
|
+
No template DSL, no VDOM, no magic compile step — just explicit reactivity and direct DOM updates.
|
|
5
|
+
|
|
6
|
+
If your UI should be fast **and** your code should still look like code, this is for you.
|
|
7
|
+
|
|
8
|
+
[<img width="938" height="361" alt="image" src="https://github.com/user-attachments/assets/774acd00-f11b-4aa8-b502-af90fc3a8436" />](http://granular.web.app/)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
[<img width="153" height="47" alt="image" src="https://github.com/user-attachments/assets/06b38444-4e19-4011-ad2e-95de98d291fd" />](https://granular.web.app/#/docs/granular/quick-start)
|
|
12
|
+
[<img width="163" height="46" alt="image" src="https://github.com/user-attachments/assets/43ac4088-125b-441c-8f17-bfc22bf989e1" />](https://granular.web.app/#/landing)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## The Pitch
|
|
17
|
+
|
|
18
|
+
- **JS-first UI**: DOM tags are functions (`Div`, `Span`, `Button`).
|
|
19
|
+
- **Granular updates**: only the nodes that change update.
|
|
20
|
+
- **Explicit reactivity**: `signal`, `state`, `after`, `before`, `set`, `compute`, `persist`.
|
|
21
|
+
- **No JSX/TSX**: no parallel language, no VDOM tree.
|
|
22
|
+
- **No build required**: runs directly in the browser (ESM).
|
|
23
|
+
- **No dependency pile-up**: no 300‑package dependency tree just to render a button.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Create a new Granular app with Vite:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm create @granularjs/app my-app
|
|
31
|
+
cd my-app
|
|
32
|
+
npm run dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install in an existing project:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @granularjs/core @granularjs/ui
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This creates a new project with:
|
|
42
|
+
- Vite dev server with hot reload
|
|
43
|
+
- granular + @granular/ui
|
|
44
|
+
- Pre-configured routing
|
|
45
|
+
- Example pages with reactivity demos
|
|
46
|
+
|
|
47
|
+
## A Tiny Example
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
const App = () => {
|
|
51
|
+
const counter = persist(state(0), { key: 'counter' });
|
|
52
|
+
|
|
53
|
+
before(counter).change((next) => {
|
|
54
|
+
return (next <= 10)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
after(counter).change(() => {
|
|
58
|
+
console.log('counter changed')
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const doubled = after(counter).compute((value) => value * 2);
|
|
62
|
+
|
|
63
|
+
return Div(
|
|
64
|
+
{ style: { fontSize: 20 } },
|
|
65
|
+
Span(counter),
|
|
66
|
+
Span(' x2 = '),
|
|
67
|
+
Span(doubled),
|
|
68
|
+
Button({ onClick: () => counter.set(counter.get() + 1) }, 'Increment')
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Why Granular (not React)
|
|
74
|
+
|
|
75
|
+
- **No virtual DOM**: no reconciler, no tree diff, no “render” ceremony.
|
|
76
|
+
- **No build tax**: skip the compile pipeline and ship ESM directly.
|
|
77
|
+
- **Real performance**: update only what changed, not an entire tree.
|
|
78
|
+
- **Explicit, readable reactivity**: `after(...targets)` and `after(...targets).compute(...)`.
|
|
79
|
+
- **Fewer moving pieces**: no metaframework, no plugin circus, no “install 738 packages”.
|
|
80
|
+
- **Functional ergonomics**: clean JS with predictable behavior (and no hook rules).
|
|
81
|
+
|
|
82
|
+
Yes, we are poking the bear — but for a reason. Complexity and over‑abstraction are not features.
|
|
83
|
+
|
|
84
|
+
## What’s in the Box
|
|
85
|
+
|
|
86
|
+
- **Core runtime**: DOM tags, renderables, granular updates.
|
|
87
|
+
- **State**: `state()` + computed values + persistence.
|
|
88
|
+
- **Context**: share reactive state across a component tree without prop drilling.
|
|
89
|
+
- **Query/Refetch**: caching, dedupe, retries.
|
|
90
|
+
- **Router**: history/hash/memory with guards and transitions.
|
|
91
|
+
- **Events**: `before/after` hooks everywhere (variadic).
|
|
92
|
+
- **SSR**: `renderToString` + `hydrate` for server HTML.
|
|
93
|
+
- **WebSockets**: client with reconnect + hooks.
|
|
94
|
+
|
|
95
|
+
## Reactive API (quick)
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const total = after(cart.items, cart.discount).compute((items, discount) => {
|
|
99
|
+
return calcTotal(items, discount);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const unsub = after(user.name, user.role).change((next) => {
|
|
103
|
+
console.log('changed:', next);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
before(form.values).change((next) => {
|
|
107
|
+
if (!next.name) return false;
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
## What the Framework Delivers
|
|
113
|
+
|
|
114
|
+
### JS‑First DOM Rendering
|
|
115
|
+
- DOM tags are functions (`Div`, `Span`, `Button`, ...).
|
|
116
|
+
- Each tag accepts **any number of arguments in any order**: props objects (HTML attributes) and children (text, renderables, signals, state, arrays). Examples: `Div('Test')`, `Div({ style: { width: '100px' } }, 'Texto', { className: 'teste' }, 'Mais Texto')`.
|
|
117
|
+
- Props are applied directly to the real DOM.
|
|
118
|
+
- Children accept primitives, renderables, arrays, and observable sources.
|
|
119
|
+
- No HTML template parsing, no VDOM.
|
|
120
|
+
Granular renders **real DOM, on demand**, with zero template gymnastics. Your UI is JavaScript, nothing else.
|
|
121
|
+
|
|
122
|
+
### Renderable Contract
|
|
123
|
+
- `Renderable` is the base mountable unit.
|
|
124
|
+
- `Renderer` normalizes values:
|
|
125
|
+
- primitive → `TextNode`
|
|
126
|
+
- `Node` → mount directly
|
|
127
|
+
- `Renderable` → mount/unmount lifecycle
|
|
128
|
+
- `Array` → flattened list
|
|
129
|
+
This keeps rendering predictable and composable, without hidden layers.
|
|
130
|
+
|
|
131
|
+
### SSR (Server‑Side Rendering)
|
|
132
|
+
`renderToString(renderable)`:
|
|
133
|
+
- Generates HTML without a DOM.
|
|
134
|
+
- Works with all built‑in renderables.
|
|
135
|
+
|
|
136
|
+
`hydrate(target, renderable)`:
|
|
137
|
+
- Attaches UI on the client after SSR.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
```js
|
|
141
|
+
const html = renderToString(App({ data }));
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### DOM Utilities
|
|
145
|
+
- `Elements` exposes all tag functions in a single object.
|
|
146
|
+
- `Renderer.normalize()` accepts primitives, nodes, renderables, and arrays.
|
|
147
|
+
|
|
148
|
+
### DOM Node Access
|
|
149
|
+
Use `node` to capture the underlying DOM element into a reactive target.
|
|
150
|
+
It accepts a `state` or `signal` and is set when the element mounts.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
```js
|
|
154
|
+
import { Div, state } from '@granularjs/core';
|
|
155
|
+
|
|
156
|
+
const rootEl = state(null);
|
|
157
|
+
|
|
158
|
+
Div({ node: rootEl }, 'Hello');
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Function Components
|
|
162
|
+
Plain functions:
|
|
163
|
+
- Components are just functions that return renderables or DOM nodes.
|
|
164
|
+
- One‑time construction of the view.
|
|
165
|
+
- Updates are granular; no re‑render loop.
|
|
166
|
+
- Uses `state()` and `after/before/set`.
|
|
167
|
+
Your component runs once. The DOM updates forever. That is the whole point.
|
|
168
|
+
|
|
169
|
+
### Signals and State
|
|
170
|
+
`signal(value)` and `state(value)`:
|
|
171
|
+
- `signal` is a small observable primitive.
|
|
172
|
+
- `state` provides proxy paths with `.get()` / `.set()` and read‑only bindings.
|
|
173
|
+
- Direct mutation of state paths is forbidden (`s.user = ...` throws).
|
|
174
|
+
- `mutate(optimistic, mutation, options?)` supports optimistic updates with rollback.
|
|
175
|
+
- `subscribe(state, selector)` returns a derived, state‑like value.
|
|
176
|
+
You get mutable ergonomics with immutable safety. No spread hell, no guesswork.
|
|
177
|
+
|
|
178
|
+
### Reactive Observers
|
|
179
|
+
`after(...targets)` / `before(...targets)`:
|
|
180
|
+
- Variadic targets (any change triggers).
|
|
181
|
+
- `change(fn)` receives `(next, prev, ctx)`.
|
|
182
|
+
- `compute(fn, options)` returns a read‑only, state‑like computed value.
|
|
183
|
+
- `before` can cancel by returning `false`.
|
|
184
|
+
- For arrays, `next` and `prev` are **lazy** (`next()` / `prev()`).
|
|
185
|
+
|
|
186
|
+
**change() — precise change handling**
|
|
187
|
+
- `next` and `prev` are values for signals/state.
|
|
188
|
+
- For arrays, `next`/`prev` are functions to avoid heavy snapshots.
|
|
189
|
+
- `ctx` includes metadata (for arrays: `ctx.patch`, `prevLength`, `nextLength`).
|
|
190
|
+
|
|
191
|
+
**compute() — derived state with intent**
|
|
192
|
+
- Same `next/prev/ctx` contract as `change()`.
|
|
193
|
+
- Supports async, debounce, hash, equality checks, and error handling.
|
|
194
|
+
|
|
195
|
+
**Array patch quick reference**
|
|
196
|
+
- `insert`: `{ type, index, items }`
|
|
197
|
+
- `remove`: `{ type, index, count, items }`
|
|
198
|
+
- `set`: `{ type, index, value, prev }`
|
|
199
|
+
- `reset`: `{ type, items, prevItems }`
|
|
200
|
+
|
|
201
|
+
**before() — control flow that no other framework has**
|
|
202
|
+
- Runs *before* the change is committed.
|
|
203
|
+
- Returning `false` cancels the change completely.
|
|
204
|
+
- This is not a hook. It is a guardrail.
|
|
205
|
+
- It lets you enforce business rules, confirm actions, block invalid state, and keep UI clean without hacks.
|
|
206
|
+
- Think of it as an interceptor for state: **the mutation only happens if you allow it**.
|
|
207
|
+
|
|
208
|
+
**after() — deterministic reactions**
|
|
209
|
+
- Runs after the change is applied.
|
|
210
|
+
- Great for side effects, analytics, syncing, or derived updates.
|
|
211
|
+
- No re-render, no virtual tree — just a direct reaction to the exact change.
|
|
212
|
+
|
|
213
|
+
### Computed / Derived State
|
|
214
|
+
`after(...targets).compute(fn, options)` and `before(...targets).compute(fn, options)`:
|
|
215
|
+
- Recomputes when any target changes.
|
|
216
|
+
- `fn(next, prev, ctx)` for a single target.
|
|
217
|
+
- `fn(nextList, prevList, ctxList)` for multiple targets.
|
|
218
|
+
- Supports async functions (last‑write‑wins).
|
|
219
|
+
- Returns a read‑only, state‑like value with `.get()` and bindings.
|
|
220
|
+
This is how you build reactive values without re-rendering anything.
|
|
221
|
+
|
|
222
|
+
Options:
|
|
223
|
+
- `debounce` delay
|
|
224
|
+
- `hash(...args)` skip if unchanged
|
|
225
|
+
- `equals(prev, next)` skip if unchanged
|
|
226
|
+
- `onError(err)` for sync/async errors
|
|
227
|
+
|
|
228
|
+
### Collections and Lists
|
|
229
|
+
`observableArray(initial)`:
|
|
230
|
+
- Emits patches (`insert`, `remove`, `set`, `reset`).
|
|
231
|
+
- Supports `before()` / `after()` hooks.
|
|
232
|
+
|
|
233
|
+
`list(items, renderItem)`:
|
|
234
|
+
- Efficient list rendering from observable arrays, signals, or state.
|
|
235
|
+
- Each item is wrapped in `state(item)` and each index in `signal(index)`.
|
|
236
|
+
- `renderItem` receives `(itemState, indexSignal)` — reactive wrappers, not raw values.
|
|
237
|
+
- On `set` patches, the existing state is updated (`itemState.set(newValue)`), so only the specific DOM nodes bound to changed properties update. No DOM destruction/recreation.
|
|
238
|
+
- Use state paths for reactive bindings: `Span(item.name)` updates only that text node.
|
|
239
|
+
- Use `.get()` inside event closures for raw values: `onClick: () => doSomething(item.id.get())`.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
```js
|
|
243
|
+
const todos = observableArray([{ text: 'Learn', done: false }]);
|
|
244
|
+
|
|
245
|
+
list(todos, (todo) => Div(
|
|
246
|
+
Span(todo.text), // reactive binding
|
|
247
|
+
Span(after(todo.done).compute(d => d ? '✓' : '○')), // reactive computed
|
|
248
|
+
Button({ onClick: () => todo.set().done = !todo.done.get() }, 'Toggle')
|
|
249
|
+
))
|
|
250
|
+
|
|
251
|
+
todos.push({ text: 'Build', done: false }); // only adds new DOM
|
|
252
|
+
todos[0] = { text: 'Master', done: true }; // only updates bound text nodes
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`when(condition, renderTrue, renderFalse)`:
|
|
256
|
+
- Reactive conditional rendering without re‑rendering parents.
|
|
257
|
+
Granular treats lists as live data structures, not as arrays you re‑map on every tick.
|
|
258
|
+
|
|
259
|
+
### Virtualization / Windowing
|
|
260
|
+
`virtualList(items, options)`:
|
|
261
|
+
- Optional fixed `itemSize` (measured automatically if omitted).
|
|
262
|
+
- Supports `direction: 'vertical' | 'horizontal'`.
|
|
263
|
+
- Viewport size is derived from the **parent element**.
|
|
264
|
+
- Only visible items are rendered (overscan supported).
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
```js
|
|
268
|
+
virtualList(rows, {
|
|
269
|
+
render: (row) => Row(row),
|
|
270
|
+
itemSize: 48,
|
|
271
|
+
direction: 'vertical',
|
|
272
|
+
overscan: 2,
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Horizontal example (auto size):
|
|
277
|
+
```js
|
|
278
|
+
virtualList(cards, {
|
|
279
|
+
render: (card) => Card(card),
|
|
280
|
+
direction: 'horizontal',
|
|
281
|
+
overscan: 3,
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Context
|
|
286
|
+
`context(defaultValue)`:
|
|
287
|
+
- Shares reactive state across a component tree without prop drilling.
|
|
288
|
+
- `scope(value?)` creates a provider level with `.get()`, `.set()`, path access, and `.serve(renderable)`.
|
|
289
|
+
- `state()` returns a reactive state bound to the nearest ancestor provider.
|
|
290
|
+
- Supports nesting: inner scopes override outer ones without affecting siblings.
|
|
291
|
+
- Works with dynamic children (`list()`, `when()`) via mount-time resolution.
|
|
292
|
+
Context gives you React-like sharing without React-like complexity. No Provider JSX, no useContext — just state that flows.
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
```js
|
|
296
|
+
import { context, Div, Text, after } from '@granularjs/core'
|
|
297
|
+
|
|
298
|
+
const themeCtx = context('light')
|
|
299
|
+
|
|
300
|
+
const ThemeProvider = (...children) => {
|
|
301
|
+
const theme = themeCtx.scope('dark')
|
|
302
|
+
return theme.serve(Div(...children))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ThemedCard = () => {
|
|
306
|
+
const theme = themeCtx.state()
|
|
307
|
+
return Div(
|
|
308
|
+
{ className: after(theme).compute(t => `card card-${t}`) },
|
|
309
|
+
Text('Current theme: ', theme)
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Usage
|
|
314
|
+
ThemeProvider(ThemedCard())
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Provider controls its own state:
|
|
318
|
+
```js
|
|
319
|
+
const sizeCtx = context([])
|
|
320
|
+
|
|
321
|
+
const Table = (...children) => {
|
|
322
|
+
const sizes = sizeCtx.scope(['1fr', '2fr', 'auto'])
|
|
323
|
+
// sizes.get(), sizes.set(), sizes[0] — full state API
|
|
324
|
+
return sizes.serve(Div(...children))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const Row = () => {
|
|
328
|
+
const sizes = sizeCtx.state()
|
|
329
|
+
// sizes is reactive, bound to the nearest Table's scope
|
|
330
|
+
return Div({ style: { gridTemplateColumns: after(sizes).compute(s => s.join(' ')) } })
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### State as Store
|
|
335
|
+
Granular does not need a separate store type. Any `state()` can be your global store.
|
|
336
|
+
|
|
337
|
+
Example (singleton module store):
|
|
338
|
+
```js
|
|
339
|
+
// user.store.js
|
|
340
|
+
export const userStore = state({ users: [] });
|
|
341
|
+
|
|
342
|
+
export const addUser = (user) => userStore.set().users = userStore.get().users.concat(user);
|
|
343
|
+
export const removeUser = (id) =>
|
|
344
|
+
userStore.set().users = userStore.get().users.filter((u) => u.id !== id);
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Selectors:
|
|
348
|
+
```js
|
|
349
|
+
const users = subscribe(userStore, (s) => s.users);
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Query / Refetch
|
|
353
|
+
`QueryClient`:
|
|
354
|
+
- Cache per key
|
|
355
|
+
- Dedupe in‑flight requests
|
|
356
|
+
- Retry with backoff
|
|
357
|
+
- `staleTime`, `cacheTime`
|
|
358
|
+
- `invalidate` and `refetch`
|
|
359
|
+
- Refetch on focus/reconnect
|
|
360
|
+
- Abortable fetch via `AbortController`
|
|
361
|
+
- Service factory with endpoint maps and middlewares
|
|
362
|
+
Server state is not special. It is just state with guarantees.
|
|
363
|
+
|
|
364
|
+
Service example:
|
|
365
|
+
```js
|
|
366
|
+
const userService = queryClient.service({
|
|
367
|
+
baseUrl: '/api',
|
|
368
|
+
middlewares: [authMiddleware],
|
|
369
|
+
endpoints: {
|
|
370
|
+
getUsers: { path: '/users', method: 'GET', map: UserDTO.from },
|
|
371
|
+
getUser: { path: '/users/:id', method: 'GET', map: UserDTO.from },
|
|
372
|
+
createUser: { path: '/users', method: 'POST', map: UserDTO.from },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const user = await userService.getUser({
|
|
377
|
+
params: { id: 1 },
|
|
378
|
+
query: { active: true },
|
|
379
|
+
headers: { 'X-Trace': '1' },
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Router
|
|
384
|
+
`Router` / `createRouter`:
|
|
385
|
+
- History, hash, and memory modes
|
|
386
|
+
- Guards, redirects, loaders
|
|
387
|
+
- Transition hooks
|
|
388
|
+
- Scroll restoration
|
|
389
|
+
- Safe path matching with priorities
|
|
390
|
+
- Nested routes with `children`
|
|
391
|
+
- Layouts via `layout(outlet, ctx)`
|
|
392
|
+
- Query syncing via `router.queryParameters()`
|
|
393
|
+
Navigation stays declarative, but the runtime stays in your control.
|
|
394
|
+
|
|
395
|
+
Example:
|
|
396
|
+
```js
|
|
397
|
+
const AppLayout = (outlet) => Div(
|
|
398
|
+
Sidebar(),
|
|
399
|
+
Div({ className: 'content' }, outlet)
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const SettingsLayout = (outlet) => Div(
|
|
403
|
+
H2('Settings'),
|
|
404
|
+
outlet
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const router = createRouter({
|
|
408
|
+
mode: 'history',
|
|
409
|
+
routes: [
|
|
410
|
+
{
|
|
411
|
+
path: '/',
|
|
412
|
+
layout: AppLayout,
|
|
413
|
+
children: [
|
|
414
|
+
{ path: '', page: Home },
|
|
415
|
+
{ path: 'dashboard', page: Dashboard },
|
|
416
|
+
{
|
|
417
|
+
path: 'settings',
|
|
418
|
+
layout: SettingsLayout,
|
|
419
|
+
children: [
|
|
420
|
+
{ path: '', page: SettingsHome },
|
|
421
|
+
{ path: 'profile', page: Profile },
|
|
422
|
+
{ path: 'billing', page: Billing },
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Query parameters:
|
|
432
|
+
```js
|
|
433
|
+
const q = router.queryParameters({ replace: false, preserveHash: true });
|
|
434
|
+
|
|
435
|
+
Input({
|
|
436
|
+
value: q.term,
|
|
437
|
+
onInput: (ev) => q.set().term = ev.target.value,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
Button({ onClick: () => q.set().page = 1 }, 'Reset page');
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Events
|
|
444
|
+
`EventHub`:
|
|
445
|
+
- Fluent `before()` / `after()` hooks
|
|
446
|
+
- Dynamic event names via Proxy
|
|
447
|
+
One event system, used everywhere. Predictable and powerful.
|
|
448
|
+
|
|
449
|
+
### Persistence / Hydration
|
|
450
|
+
`persist(state, options)`:
|
|
451
|
+
- Returns the same target for chaining.
|
|
452
|
+
- Hydrates first, then subscribes and saves.
|
|
453
|
+
- Default serializer drops functions/symbols.
|
|
454
|
+
- `reconcile(snapshot)` can rebuild non‑serializable fields.
|
|
455
|
+
Your app survives refreshes without manual glue code.
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
```js
|
|
459
|
+
const profile = persist(state({ name: 'Ana', format: (v) => v.toUpperCase() }), {
|
|
460
|
+
key: 'profile',
|
|
461
|
+
reconcile: (snap) => ({ ...snap, format: (v) => v.toUpperCase() }),
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Form Management
|
|
466
|
+
`form(initial)` returns:
|
|
467
|
+
- `values`, `meta`, `errors`, `touched`, `dirty` (state‑like)
|
|
468
|
+
- `validators` (Set with `add/delete/clear`)
|
|
469
|
+
- `reset()` restores initial snapshot
|
|
470
|
+
|
|
471
|
+
Validators contract:
|
|
472
|
+
- `fn(values)` returns `true | false | string | object | Promise<...>`
|
|
473
|
+
- `true/undefined` → ok
|
|
474
|
+
- `false` → form error (`_form = true`)
|
|
475
|
+
- `string` → form error message
|
|
476
|
+
- `object` → field errors merged by key
|
|
477
|
+
Forms stop being a framework within the framework. This is just state, done right.
|
|
478
|
+
|
|
479
|
+
### Input Formatting
|
|
480
|
+
Inputs accept a `format` prop that can be a string pattern, a regex, a formatter function, or a config object.
|
|
481
|
+
Formatting returns `{ value, visual, raw }` and supports `mode`:
|
|
482
|
+
- `both` (default): state stores formatted value, input shows formatted visual
|
|
483
|
+
- `value-only`: state stores formatted value, input shows raw
|
|
484
|
+
- `visual-only`: state stores raw, input shows formatted visual
|
|
485
|
+
|
|
486
|
+
Pattern tokens:
|
|
487
|
+
- `d` digit
|
|
488
|
+
- `a` letter
|
|
489
|
+
- `*` alphanumeric
|
|
490
|
+
- `s` non-alphanumeric
|
|
491
|
+
|
|
492
|
+
Example:
|
|
493
|
+
```js
|
|
494
|
+
import { Input, state } from '@granularjs/core';
|
|
495
|
+
|
|
496
|
+
const phone = state('');
|
|
497
|
+
|
|
498
|
+
Input({
|
|
499
|
+
value: phone,
|
|
500
|
+
format: { pattern: '(ddd) ddd-dddd', mode: 'visual-only' },
|
|
501
|
+
});
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Optimistic Updates
|
|
505
|
+
`state.mutate(optimistic, mutation, options?)`:
|
|
506
|
+
- Applies the optimistic change immediately.
|
|
507
|
+
- Rolls back automatically on error.
|
|
508
|
+
- Optional `rollback` and `clone` for control.
|
|
509
|
+
|
|
510
|
+
Example:
|
|
511
|
+
```js
|
|
512
|
+
await userState.mutate(
|
|
513
|
+
() => userState.set().name = 'Guilherme',
|
|
514
|
+
() => userService.saveUser(userState.get())
|
|
515
|
+
);
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Error Boundaries
|
|
519
|
+
`ErrorBoundary({ fallback, onError }, child)`:
|
|
520
|
+
- Catches runtime errors inside a subtree.
|
|
521
|
+
- Renders the fallback when an error happens.
|
|
522
|
+
- `onError` receives the error and context.
|
|
523
|
+
|
|
524
|
+
Example:
|
|
525
|
+
```js
|
|
526
|
+
ErrorBoundary(
|
|
527
|
+
{ fallback: () => Div('Ops'), onError: (err) => console.error(err) },
|
|
528
|
+
() => Div('OK')
|
|
529
|
+
);
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
### Portals / Overlays
|
|
534
|
+
`portal(target, content)`:
|
|
535
|
+
- Renders UI outside the normal DOM hierarchy.
|
|
536
|
+
- `target` can be a selector or a DOM element.
|
|
537
|
+
Portals are how you build modals, toasts and overlays without fighting layout or z‑index wars.
|
|
538
|
+
Portals are **renderables**: they must exist in the render tree to mount, and they unmount when removed from the tree.
|
|
539
|
+
|
|
540
|
+
Example:
|
|
541
|
+
```js
|
|
542
|
+
portal('#overlay', () => Div({ className: 'modal' }, 'Hello'));
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Controlled usage (recommended):
|
|
546
|
+
```js
|
|
547
|
+
const open = state(false);
|
|
548
|
+
|
|
549
|
+
const App = () => Div(
|
|
550
|
+
Button({ onClick: () => open.set(true) }, 'Open'),
|
|
551
|
+
when(open, () =>
|
|
552
|
+
portal(() => Div(
|
|
553
|
+
{ className: 'modal' },
|
|
554
|
+
Button({ onClick: () => open.set(false) }, 'Close')
|
|
555
|
+
))
|
|
556
|
+
)
|
|
557
|
+
);
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### WebSockets
|
|
561
|
+
`createWebSocket(options)`:
|
|
562
|
+
- Auto‑connect with reconnect support.
|
|
563
|
+
- `before/after` hooks for `message` and `send`.
|
|
564
|
+
- Reactive state via `ws.state()`.
|
|
565
|
+
|
|
566
|
+
Example:
|
|
567
|
+
```js
|
|
568
|
+
const ws = createWebSocket({ url: 'wss://example.com' });
|
|
569
|
+
|
|
570
|
+
ws.after().message(({ data }) => {
|
|
571
|
+
console.log('message', data);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
ws.send({ type: 'ping' });
|
|
575
|
+
```
|
|
576
|
+
|