weft 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
|
@@ -16,12 +16,12 @@ CAR_MODELS = {
|
|
|
16
16
|
class ModelSelect < Weft::Component
|
|
17
17
|
builder_method :model_select
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
param :make, default: "audi"
|
|
20
20
|
|
|
21
21
|
def build(attributes = {})
|
|
22
22
|
super
|
|
23
23
|
set_attribute :name, "model"
|
|
24
|
-
CAR_MODELS.fetch(
|
|
24
|
+
CAR_MODELS.fetch(params.make).each do |model|
|
|
25
25
|
option model, value: model.downcase
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -41,7 +41,7 @@ class CarsPage < Weft::Page
|
|
|
41
41
|
div do
|
|
42
42
|
label "Make ", for: "make"
|
|
43
43
|
select name: "make", id: "make",
|
|
44
|
-
loads: ModelSelect, trigger:
|
|
44
|
+
loads: ModelSelect, trigger: :change, swap: :fill, target: "#models" do
|
|
45
45
|
option "Audi", value: "audi"
|
|
46
46
|
option "Toyota", value: "toyota"
|
|
47
47
|
option "BMW", value: "bmw"
|
|
@@ -50,7 +50,7 @@ class CarsPage < Weft::Page
|
|
|
50
50
|
div do
|
|
51
51
|
label "Model "
|
|
52
52
|
span id: "models" do
|
|
53
|
-
model_select
|
|
53
|
+
model_select
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -61,13 +61,13 @@ end
|
|
|
61
61
|
|
|
62
62
|
## How it works
|
|
63
63
|
|
|
64
|
-
**The select's own value is the parameter.** The [`loads:`](../dsl.md#loads) kwarg generates a GET to `ModelSelect`'s route with *no* query string — and htmx completes it at request time: per [its parameter rules](https://htmx.org/docs/#parameters), the element that causes a request includes its own `name=value`. Changing the select to Toyota therefore sends `GET /_components/model_select?make=toyota`, and the component's declared `make`
|
|
64
|
+
**The select's own value is the parameter.** The [`loads:`](../dsl.md#loads) kwarg generates a GET to `ModelSelect`'s route with *no* query string — and htmx completes it at request time: per [its parameter rules](https://htmx.org/docs/#parameters), the element that causes a request includes its own `name=value`. Changing the select to Toyota therefore sends `GET /_components/model_select?make=toyota`, and the component's declared `make` param picks the parameter up on the server (see [Params](../dsl.md#params)). That inclusion is htmx client-side behavior — you won't see it in any rendered attribute — so if the models ever fail to repopulate, the first thing to check is that the triggering select still has its `name`.
|
|
65
65
|
|
|
66
|
-
**Keep the URL clean of `with:`.** Baking `with: { make: ... }` into the URL would freeze the value at render time, fighting the live selection htmx appends. In page markup, simply omitting `with:` does the right thing. Inside a *component's* `build`, though, an omitted `with:` defaults to that component's current
|
|
66
|
+
**Keep the URL clean of `with:`.** Baking `with: { make: ... }` into the URL would freeze the value at render time, fighting the live selection htmx appends. In page markup, simply omitting `with:` does the right thing. Inside a *component's* `build`, though, an omitted `with:` defaults to that component's current params — pass an explicit `with: {}` there to keep the live value as the only parameter.
|
|
67
67
|
|
|
68
|
-
**The component is the `<select>` itself.** Overriding `tag_name` (an [Arbre-layer move](../arbre.md#inside-build-the-component-contract)) makes the wrapper element a `<select>` rather than the default `<div>`, so the fetched fragment drops into the form as a real form control. Note that its `name` attribute is set inside `build` rather than at the call site: a fragment fetched over the wire is rebuilt from its declared
|
|
68
|
+
**The component is the `<select>` itself.** Overriding `tag_name` (an [Arbre-layer move](../arbre.md#inside-build-the-component-contract)) makes the wrapper element a `<select>` rather than the default `<div>`, so the fetched fragment drops into the form as a real form control. Note that its `name` attribute is set inside `build` rather than at the call site: a fragment fetched over the wire is rebuilt from its declared params alone, so any wrapper attribute the pattern depends on belongs in `build`. And its DOM id derives from the `make` value, changing with every swap — which is why the make select targets the stable `#models` slot with `swap: :fill` instead of chasing the select by id.
|
|
69
69
|
|
|
70
|
-
**`trigger:
|
|
70
|
+
**`trigger: :change` is spelled out for clarity.** It's also htmx's default trigger for a `<select>`, which is why the original htmx example omits it; keeping it explicit costs one kwarg and makes the interaction readable at the call site.
|
|
71
71
|
|
|
72
72
|
## On the wire
|
|
73
73
|
|
|
@@ -100,10 +100,10 @@ Choosing Toyota sends `GET /_components/model_select?make=toyota`, which returns
|
|
|
100
100
|
</select>
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
— and htmx fills `#models` with it. With no `make` parameter at all, the
|
|
103
|
+
— and htmx fills `#models` with it. With no `make` parameter at all, the param's default renders the Audi list; parameters the component doesn't declare are simply ignored.
|
|
104
104
|
|
|
105
105
|
## Related
|
|
106
106
|
|
|
107
107
|
- [Click to Edit](click-to-edit.md) — the same `loads:` machinery swapping whole UI states instead of one control.
|
|
108
|
-
- [`loads:`](../dsl.md#loads) and [`trigger:`](../dsl.md#trigger) in the DSL reference; [
|
|
109
|
-
- The [`live_search:`
|
|
108
|
+
- [`loads:`](../dsl.md#loads) and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Params](../dsl.md#params) for how parameters become `params`.
|
|
109
|
+
- The [`live_search:` preset](../dsl.md#presets) is this same fetch-into-a-slot pattern, triggered by typing instead of selecting.
|
data/docs/params.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# How params flow
|
|
2
|
+
|
|
3
|
+
A Weft component's data — the values it renders from, the record it looks up, the id it needs to refresh itself — all arrive through one object: `params`. This page follows that data from the moment a request lands to the moment a component re-renders itself, and shows where each of the four declarations (`param`, `receives`, `derives`, `defines`) fits. For the precise behavior of each, see [the DSL reference](dsl.md#params); this page is the map, not the legend.
|
|
4
|
+
|
|
5
|
+
The whole model in one breath: **a request's params flow in and down the render tree; each component pulls out the ones it declares; and each component renders carrying enough of its own wire state that a later request — a refresh, an action — can reconstruct it from scratch.**
|
|
6
|
+
|
|
7
|
+
## A request comes in
|
|
8
|
+
|
|
9
|
+
Every render starts with a request, and a request carries **wire params**: the query string, the path segments, and any form body, all as strings. Weft routes the request to a page (or a standalone component), which resolves its declared `param`s from those wire values — coercing each into its declared `type:`, and filling in defaults where the request said nothing.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class OrderPage < Weft::Page
|
|
13
|
+
self.page_path = "/orders/:order_id"
|
|
14
|
+
param :order_id
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A GET to `/orders/42` gives this page `params.order_id == "42"`. Nothing else about the request — session, headers, cookies — is part of `params`; that channel is deliberately just the request's own parameters. (For per-request identity like the current user, see [Application patterns](app-patterns.md#authentication-and-sessions).)
|
|
19
|
+
|
|
20
|
+
## Params flow down the render tree
|
|
21
|
+
|
|
22
|
+
A page is rarely one component. It embeds others, which embed others. Within a single render, **each component starts from a copy of its nearest ancestor's resolved params** — it sees everything above it in the tree, nothing beside it.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class OrderPage < Weft::Page
|
|
26
|
+
param :order_id
|
|
27
|
+
|
|
28
|
+
def build(attributes = {})
|
|
29
|
+
super
|
|
30
|
+
order_summary # embedded — no arguments
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class OrderSummary < Weft::Component
|
|
35
|
+
builder_method :order_summary
|
|
36
|
+
# declares no order_id of its own …
|
|
37
|
+
|
|
38
|
+
def build(attributes = {})
|
|
39
|
+
super
|
|
40
|
+
h2 "Order #{params.order_id}" # … yet reads it, inherited from the page
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`OrderSummary` never declares `order_id`, but because it renders inside a page that has it, it reads `params.order_id` for free. This is why embedding is so quiet in Weft: a child that needs what its parent already holds just reads it. Declaring the key anyway (`param :order_id`) is often worth it — it documents the dependency and, as the next sections show, lets the child stand on its own — but it isn't required merely to *read* an inherited value.
|
|
46
|
+
|
|
47
|
+
## Four doors: how a component gets what it needs
|
|
48
|
+
|
|
49
|
+
When a component wants to control a value rather than inherit it, it declares that value — through whichever of the four doors suits the value's nature:
|
|
50
|
+
|
|
51
|
+
- **`param`** — wire state, small enough to travel in a URL: an id, a page number, a filter. Comes from the request, or is inherited from above.
|
|
52
|
+
- **`derives`** — a value the component computes for itself, lazily, the first time it's read.
|
|
53
|
+
- **`defines`** — a static value a subclass pins; sugar over `derives`.
|
|
54
|
+
- **`receives`** — a rich object a caller hands over directly: a record, a built collection, anything that can't ride a query string.
|
|
55
|
+
|
|
56
|
+
A single key can have more than one door, and they resolve in a fixed order — a handed value beats a request overlay (a hash a verb block returned earlier in the request) beats a wire value beats an inherited value beats a derivation beats a default. The [DSL reference](dsl.md#how-the-doors-combine) lays out that precedence and the useful *dual* combinations; the shape to carry away here is that all four doors land in the same `params`, read the same way (`params.name`).
|
|
57
|
+
|
|
58
|
+
Three of the four are open to every verb block too — an action callable, a `transfers`, `includes` or `recovers` block all read the same `param`, `derives` and `defines` a `build` would. Only `receives` is missing there, and it has to be: a hand-off comes from a call site, and a request arriving over the wire has none. When a block needs such a key, give it a second door.
|
|
59
|
+
|
|
60
|
+
## What a component keeps for itself
|
|
61
|
+
|
|
62
|
+
Here is the pivot that makes the whole system hold together: **only a component's own declared `param`s are serialized.** When Weft renders a component, it bakes that component's wire params into the things that will make the *next* request on its behalf —
|
|
63
|
+
|
|
64
|
+
- its **refresh URL** (`refreshes`) and **stream URL** (`pushes`),
|
|
65
|
+
- the **payload of every action** it declares (`performs`, `transfers`, `dismisses`).
|
|
66
|
+
|
|
67
|
+
The other three doors never serialize: you can't put an `Order` object in a query string, and a derived value can always be re-derived. So what travels forward is exactly the URL-safe wire state the component declared with `param`, and nothing else. Inherited values don't travel either — a child that merely *read* its parent's `order_id` doesn't carry it. A child that needs `order_id` on the next request must declare it.
|
|
68
|
+
|
|
69
|
+
The same discipline applies to a component's HTML attributes. Chrome passed at the call site (`status_card(class: "wide", name: "picker")`) exists only in that in-page render — a wire re-render rebuilds the component from its params alone, and the call-site attributes are gone. An attribute the component *depends on* (a `name` the pattern reads, an ARIA role) belongs inside `build` via `set_attribute`, where every render path reproduces it.
|
|
70
|
+
|
|
71
|
+
## The round trip: refresh and actions
|
|
72
|
+
|
|
73
|
+
This is the payoff. Because a rendered component carries its own wire params, it can regenerate itself without its parent in the picture:
|
|
74
|
+
|
|
75
|
+
- A **refresh** is a GET to the component's own route, its own params in the query string. Weft routes straight to that component, resolves those params from the wire — the first step again — and re-renders.
|
|
76
|
+
- An **action** is a POST (or DELETE) to the component's route, its params in the payload. The callable runs, then the component re-renders — and the re-render, nested components and OOB companions included, resolves against the *same request wire*, with the callable's returned hash overlaid on top. One universe per request, amended by the verbs that run in it: no matter how rendering flows inside a request (an action re-render, a `transfers` hand-off, an error recovery), a component that reads a wire param keeps reading it, without any outer component relaying it.
|
|
77
|
+
|
|
78
|
+
The callable and the render that follows it are two points on **one chain**, not two independent resolutions. Weft composes the component's params from the wire, hands that to the callable, layers whatever the callable returned on top, and passes the result on to the render — which branches it the same way a child branches its parent's. So a callable reads the same `derives` its `build` does, and a derivation it forces is already a value by the time the render, and the companions riding alongside, read the same key. One lookup, one response.
|
|
79
|
+
|
|
80
|
+
So "render with enough to get where it needs" is literal: whatever a component will need to reconstruct itself on the next request, it must hold as its own `param`s at render time, because that is what gets serialized into the refresh URL and the action payload. A self-refreshing card embedded as `status_card(status: "hot")` keeps refreshing correctly *only* if it declares `param :status` — otherwise the refresh request carries no status and the standalone re-render has nothing to go on.
|
|
81
|
+
|
|
82
|
+
## Lists, and why `receives` exists
|
|
83
|
+
|
|
84
|
+
Inheritance flows one value down to every descendant — which is exactly wrong for a list, where each row needs a *different* value. Siblings share their parent's bag; they can't each inherit a distinct id. So the parent must **hand** each row its own value, and that is what `receives` is for:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class ContactRow < Weft::Component
|
|
88
|
+
builder_method :contact_row
|
|
89
|
+
|
|
90
|
+
param :contact_id # serialized — lets the row refresh and act on its own
|
|
91
|
+
receives :contact_id # handed — the table gives each row its distinct id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class ContactsTable < Weft::Component
|
|
95
|
+
builder_method :contacts_table
|
|
96
|
+
|
|
97
|
+
def build(attributes = {})
|
|
98
|
+
super
|
|
99
|
+
tbody do
|
|
100
|
+
CONTACT_BOOK.each_key { |id| contact_row(contact_id: id) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The `receives :contact_id` is what consumes the `contact_row(contact_id: id)` hand-off — without it, that kwarg would fall through and render as a stray HTML attribute. The `param :contact_id` alongside it is what lets each row stand on its own: when a row fires its delete action, its id is already serialized into the payload, so the server knows which contact to remove. Handed when embedded, wire-borne when acting — two doors, one key. This *dual* is the backbone of every interactive list; you'll see it in [Delete Row](examples/delete-row.md) and [Edit Row](examples/edit-row.md).
|
|
107
|
+
|
|
108
|
+
## The shape of it
|
|
109
|
+
|
|
110
|
+
End to end: a request's wire params resolve into the top component and flow down the tree; each component reads what it inherits and declares what it wants to own; at render time each bakes its own wire params into its refresh URL and action payloads; and the next request — refresh or action — arrives carrying exactly what that component needs to do it all again. Data in from the wire, data down the tree, data forward into the next request. That loop is the whole of it.
|
|
111
|
+
|
|
112
|
+
That loop moves *data* down the tree. Once in a while a component needs to reach the other way — *up* the tree, for an ancestor's **identity** rather than its data: a nested pager needs its enclosing panel's route and DOM id to aim a swap at it. That's a separate affordance, [`closest` / `enclosing`](arbre.md#reaching-enclosing-components) — not part of the params flow, but its natural complement. Params come *to* you; identity you reach *for*.
|
data/docs/routing.md
CHANGED
|
@@ -30,7 +30,7 @@ One prerequisite worth knowing: registration happens when a class is *defined* (
|
|
|
30
30
|
|
|
31
31
|
## Component routes
|
|
32
32
|
|
|
33
|
-
Every routable component is addressable at a GET route that renders it as an HTML fragment — the mechanism behind `refreshes`, `navigate:`, `loads:`, and the
|
|
33
|
+
Every routable component is addressable at a GET route that renders it as an HTML fragment — the mechanism behind `refreshes`, `navigate:`, `loads:`, and the presets, and equally usable directly (`curl` it; you'll get the component's HTML).
|
|
34
34
|
|
|
35
35
|
The path derives from the class name: strip a trailing `Component` if present, snake-case what's left, prefix `/_components/`. Namespaces become path segments.
|
|
36
36
|
|
|
@@ -42,7 +42,7 @@ The path derives from the class name: strip a trailing `Component` if present, s
|
|
|
42
42
|
|
|
43
43
|
The suffix-stripping means `OrdersPanel` and `OrdersPanelComponent` are the same route — pick whichever naming style your app prefers, consistently. The `/_components/` prefix keeps the fragment namespace visibly separate from your page URLs; the leading underscore marks it as infrastructure.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Params arrive as query parameters (`/_components/orders_panel?status=shipped&page=2`) and resolve through the component's declared schema — undeclared parameters are ignored, and declared ones are coerced per their declared `type:` (see [Params](dsl.md#params)).
|
|
46
46
|
|
|
47
47
|
To change the path for one class, set `component_path` on it — a string, or a proc receiving the class:
|
|
48
48
|
|
|
@@ -69,30 +69,30 @@ A component declaring `pushes` also gets an SSE endpoint at its path plus the st
|
|
|
69
69
|
|
|
70
70
|
## Page routes
|
|
71
71
|
|
|
72
|
-
Pages route as full HTML documents at people-facing URLs — no prefix, no derivation from fragments. A page declares its pattern with `page_path`, Sinatra-style, with `:param` segments mapping to
|
|
72
|
+
Pages route as full HTML documents at people-facing URLs — no prefix, no derivation from fragments. A page declares its pattern with `page_path`, Sinatra-style, with `:param` segments mapping to params:
|
|
73
73
|
|
|
74
74
|
```ruby
|
|
75
75
|
class OrderDetailPage < Weft::Page
|
|
76
76
|
self.page_path = "/orders/:order_id"
|
|
77
|
-
|
|
77
|
+
param :order_id
|
|
78
78
|
end
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
A request for `/orders/42` renders the page with `
|
|
81
|
+
A request for `/orders/42` renders the page with `params.order_id == "42"`. Path parameters merge with query and body parameters (path wins on conflicts), and the combined set resolves through the page's param schema like any other wire state.
|
|
82
82
|
|
|
83
|
-
The pattern is bidirectional — it also builds URLs. `Weft.redirect(OrderDetailPage, order_id: 42)` interpolates the
|
|
83
|
+
The pattern is bidirectional — it also builds URLs. `Weft.redirect(OrderDetailPage, order_id: 42)` interpolates the params into the pattern, and `OrderDetailPage.redirect_url(order_id: 42, highlight: "items")` additionally turns declared-but-not-in-path params into a query string (undeclared keys are discarded, never leaked into URLs).
|
|
84
84
|
|
|
85
85
|
Pages without an explicit `page_path` infer one from the class name: demodulized, snake-cased, with a trailing `Page` stripped if present — `DashboardPage` and `Dashboard` both route at `/dashboard`. Two edges of the inference to know:
|
|
86
86
|
|
|
87
|
-
- A page with **
|
|
87
|
+
- A page with **params** must declare `page_path` explicitly — a parameterized pattern can't be guessed from a name, so Weft raises with the pattern it suggests rather than inventing one.
|
|
88
88
|
- A page named such that nothing usable remains after stripping (`Admin::Page`) also raises, with the remediation options spelled out.
|
|
89
89
|
|
|
90
90
|
## What routes — and what doesn't
|
|
91
91
|
|
|
92
92
|
Registration and routability are separate ideas. *Every* `Weft::Component` and `Weft::Page` subclass registers; whether it gets a route is inferred from what it declares:
|
|
93
93
|
|
|
94
|
-
- A **component** is routable when it declares interactive behavior: any
|
|
95
|
-
- A **page** is routable when it has a usable path: an explicit `page_path`, or a name the default can be derived from (and no
|
|
94
|
+
- A **component** is routable when it declares interactive behavior: any param, action, `refreshes`, or `pushes`. A purely presentational component — just a `build` method — registers but is never served; there's nothing to address it *for*.
|
|
95
|
+
- A **page** is routable when it has a usable path: an explicit `page_path`, or a name the default can be derived from (and no params, per the edge above).
|
|
96
96
|
|
|
97
97
|
### `abstract!` and `routable!`
|
|
98
98
|
|
|
@@ -108,9 +108,9 @@ end
|
|
|
108
108
|
|
|
109
109
|
### Routable vs. render target
|
|
110
110
|
|
|
111
|
-
"Routable" means *addressable at its own GET URL* — and that is orthogonal to being a **render target**. Verbs with transfer semantics (`transfers to:`, `recovers with:`) render their target on the server, inside an in-flight response; the target class needs
|
|
111
|
+
"Routable" means *addressable at its own GET URL* — and that is orthogonal to being a **render target**. Verbs with transfer semantics (`transfers to:`, `recovers with:`) render their target on the server, inside an in-flight response; the target class needs params to render with, but no route of its own. Weft's own default error components work exactly this way: they're `abstract!`, unreachable by URL, and rendered constantly.
|
|
112
112
|
|
|
113
|
-
Navigation-semantic wiring (`refreshes`, `navigate:`, `loads:`,
|
|
113
|
+
Navigation-semantic wiring (`refreshes`, `navigate:`, `loads:`, presets) *does* need its target addressable — and being the *target* of another component's `loads:` or preset kwarg confers nothing by itself: a target with no params and no verbs of its own stays off the route table. Weft refuses to wire a click that could only 404: a `loads:` or preset kwarg naming a non-routable class raises `Weft::InvalidUsage` at render time, as does a `navigate:` inside a component marked `abstract!`/`dependent!`. Most real targets declare params and route on their own; for a purely presentational one, declare `routable!` explicitly — the error says so. Where you'll reach for `abstract!` is the opposite case: a transfer target that declares params (so it can render) but should never be an endpoint — declare it abstract and it carries params for rendering while staying off the route table, because transfer rendering happens server-side and never fetches.
|
|
114
114
|
|
|
115
115
|
## Collision detection
|
|
116
116
|
|
|
@@ -126,6 +126,6 @@ The same validation rejects malformed paths (anything that isn't a string beginn
|
|
|
126
126
|
|
|
127
127
|
## Code reloading
|
|
128
128
|
|
|
129
|
-
Development-mode reloaders redefine constants, which would strand the *old* class object in Weft's registry —
|
|
129
|
+
Development-mode reloaders redefine constants, which would strand the *old* class object in Weft's registry — a stale twin at the same path that reads as a route collision, or worse, a deleted class whose route keeps serving. The registry's answer is eviction: `Weft.registry.evict(klass)` removes a class and re-arms route validation, so the fresh definition (or nothing, if the file is gone) takes over cleanly.
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
With [`Weft.configure_autoloading`](configuration.md#autoloading-weftconfigure_autoloading) and `reload: true`, this is wired for you — Zeitwerk announces each constant it unloads and Weft evicts it on the spot. If you drive your own reloader, call `evict` from its unload hook, or use `Weft.registry.clear` to rebuild registration from scratch on each reload. A collision error naming two classes with the same name is the tell that a reload happened without eviction.
|
data/docs/tutorial.md
CHANGED
|
@@ -68,23 +68,16 @@ require "weft"
|
|
|
68
68
|
|
|
69
69
|
APP_ROOT = File.expand_path("..", __dir__)
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
Dir[File.join(APP_ROOT, "app", dir, "*.rb")].sort.each { |file| require file }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
Weft.configure do |c|
|
|
79
|
-
c.auto_reload = true
|
|
80
|
-
c.reload_paths = [File.join(APP_ROOT, "app", "**", "*.rb")]
|
|
81
|
-
end
|
|
71
|
+
Weft.configure_autoloading(
|
|
72
|
+
paths: %w[data components pages].map { |dir| File.join(APP_ROOT, "app", dir) },
|
|
73
|
+
reload: true
|
|
74
|
+
)
|
|
82
75
|
```
|
|
83
76
|
|
|
84
77
|
Two things to notice:
|
|
85
78
|
|
|
86
|
-
- **Loading is
|
|
87
|
-
-
|
|
79
|
+
- **Loading is managed for you.** `configure_autoloading` puts [Zeitwerk](https://github.com/fxn/zeitwerk) in charge of the `app` directories: each file defines the constant its name implies (`event_store.rb` ↔ `EventStore`), references between files resolve on demand, and load order is never your problem. Weft discovers your pages and components the moment their classes load — there's nothing to register.
|
|
80
|
+
- **`reload: true` is the development loop.** In a moment you'll be editing files and refreshing the browser; with it, your edits apply without restarting the server. (In a real app you'd gate it on an environment check — see [Configuration](configuration.md#autoloading-weftconfigure_autoloading).)
|
|
88
81
|
|
|
89
82
|
## 3. Your first page
|
|
90
83
|
|
|
@@ -92,8 +85,9 @@ Create `app/pages/events_page.rb`:
|
|
|
92
85
|
|
|
93
86
|
```ruby
|
|
94
87
|
class EventsPage < Weft::Page
|
|
88
|
+
title "Upcoming Events"
|
|
89
|
+
|
|
95
90
|
def build(attributes = {})
|
|
96
|
-
attributes[:title] = "Upcoming Events"
|
|
97
91
|
super
|
|
98
92
|
h1 "Upcoming Events"
|
|
99
93
|
para "If you can read this in the browser, the app is wired up."
|
|
@@ -101,7 +95,7 @@ class EventsPage < Weft::Page
|
|
|
101
95
|
end
|
|
102
96
|
```
|
|
103
97
|
|
|
104
|
-
A page is a class. `build` describes
|
|
98
|
+
A page is a class. The `title` declaration names the browser tab; `build` describes the content using [Arbre](arbre.md)'s HTML builder methods — `h1`, `ul`, `div`, and friends — as plain Ruby. The `super` call renders the document shell around you: doctype, `<head>` with your title and the htmx script, `<body>`.
|
|
105
99
|
|
|
106
100
|
Start the server and have a look:
|
|
107
101
|
|
|
@@ -124,7 +118,7 @@ Nobody told Weft about that URL. The route came from the class name: `EventsPage
|
|
|
124
118
|
Two more things worth ten seconds each while the server is up:
|
|
125
119
|
|
|
126
120
|
- Visit [http://localhost:9292/](http://localhost:9292/) — a styled "Not found" page, for free. Weft ships default error and not-found handling out of the box ([Error handling](error-handling.md)).
|
|
127
|
-
- Edit the `para` text and refresh — the change appears without a restart. That's `
|
|
121
|
+
- Edit the `para` text and refresh — the change appears without a restart. That's `reload: true` earning its keep.
|
|
128
122
|
|
|
129
123
|
## 4. Some data
|
|
130
124
|
|
|
@@ -157,8 +151,9 @@ And make `EventsPage` list the real events:
|
|
|
157
151
|
|
|
158
152
|
```ruby
|
|
159
153
|
class EventsPage < Weft::Page
|
|
154
|
+
title "Upcoming Events"
|
|
155
|
+
|
|
160
156
|
def build(attributes = {})
|
|
161
|
-
attributes[:title] = "Upcoming Events"
|
|
162
157
|
super
|
|
163
158
|
h1 "Upcoming Events"
|
|
164
159
|
ul do
|
|
@@ -175,9 +170,9 @@ end
|
|
|
175
170
|
|
|
176
171
|
(`text_node` inserts plain text next to other elements — handy when a line mixes a link and loose text.)
|
|
177
172
|
|
|
178
|
-
**
|
|
173
|
+
**No restart needed — not even for a new file.** `event_store.rb` didn't exist when the server booted, but the loader discovers it on your next refresh, and `EventsPage`'s `EventStore` reference resolves on demand.
|
|
179
174
|
|
|
180
|
-
|
|
175
|
+
Refresh `/events`: both events appear as links. They 404 — let's fix that.
|
|
181
176
|
|
|
182
177
|
## 5. The event page
|
|
183
178
|
|
|
@@ -187,13 +182,14 @@ Create `app/pages/event_page.rb`:
|
|
|
187
182
|
class EventPage < Weft::Page
|
|
188
183
|
self.page_path = "/events/:event_id"
|
|
189
184
|
|
|
190
|
-
|
|
185
|
+
param :event_id
|
|
186
|
+
|
|
187
|
+
title { |params| EventStore.find(params.event_id).name }
|
|
191
188
|
|
|
192
189
|
def build(attributes = {})
|
|
193
|
-
event = EventStore.find(
|
|
194
|
-
raise Weft::NotFound, "no event called #{
|
|
190
|
+
event = EventStore.find(params.event_id)
|
|
191
|
+
raise Weft::NotFound, "no event called #{params.event_id}" unless event
|
|
195
192
|
|
|
196
|
-
attributes[:title] = event.name
|
|
197
193
|
super
|
|
198
194
|
h1 event.name
|
|
199
195
|
para "#{event.date} — #{event.location}"
|
|
@@ -204,9 +200,9 @@ end
|
|
|
204
200
|
|
|
205
201
|
Restart (new file), then click through to an event. Two new ideas here:
|
|
206
202
|
|
|
207
|
-
**
|
|
203
|
+
**Params are a page's wire state.** `param :event_id` declares that this page is parameterized, and the `page_path` pattern says where the value comes from: `/events/summer-bbq` gives the page `event_id = "summer-bbq"`. A page with params needs an explicit `page_path` — there's no way to derive a parameterized pattern from a class name, and Weft will tell you exactly that if you forget.
|
|
208
204
|
|
|
209
|
-
|
|
205
|
+
**The title can be dynamic.** The block form of `title` receives the page's resolved params, so each event names its own browser tab. The block runs while `super` renders the `<head>` — after the guard at the top of `build` has already raised for a bogus id, so it can assume the event exists. (And looking the event up twice is fine against a hash; when lookups get real, [`derives`](params.md#four-doors-how-a-component-gets-what-it-needs) declares the record once and every reader — the title block included — shares it.)
|
|
210
206
|
|
|
211
207
|
**Raising is error handling.** For an unknown event, we `raise Weft::NotFound` and we're done — Weft turns it into a proper 404 response with its default not-found page. Try [http://localhost:9292/events/nope](http://localhost:9292/events/nope). There's a whole family of semantic errors (`Weft::Unprocessable` will appear shortly), and everything about the resulting rendering is customizable — see [Error handling](error-handling.md).
|
|
212
208
|
|
|
@@ -218,11 +214,11 @@ Pages are destinations; **components** are the composable, interactive pieces in
|
|
|
218
214
|
class AttendeeList < Weft::Component
|
|
219
215
|
builder_method :attendee_list
|
|
220
216
|
|
|
221
|
-
|
|
217
|
+
param :event_id
|
|
222
218
|
|
|
223
219
|
def build(attributes = {})
|
|
224
220
|
super
|
|
225
|
-
event = EventStore.find(
|
|
221
|
+
event = EventStore.find(params.event_id)
|
|
226
222
|
h2 "Who's coming"
|
|
227
223
|
if event.rsvps.empty?
|
|
228
224
|
para "No RSVPs yet. Be the first!"
|
|
@@ -242,9 +238,11 @@ end
|
|
|
242
238
|
Add it to `EventPage`, before the back-link:
|
|
243
239
|
|
|
244
240
|
```ruby
|
|
245
|
-
attendee_list
|
|
241
|
+
attendee_list
|
|
246
242
|
```
|
|
247
243
|
|
|
244
|
+
Notice there's no `event_id:` here. The component is nested inside a page that already carries `event_id`, and params flow down the render tree — so `attendee_list` inherits it automatically. That inheritance is central to how Weft composes; the [DSL reference](dsl.md#inheritance-and-the-render-tree) has the full picture.
|
|
245
|
+
|
|
248
246
|
Restart, and the Summer BBQ page shows Priya under "Who's coming".
|
|
249
247
|
|
|
250
248
|
View the page source and look at the wrapper Weft rendered:
|
|
@@ -253,7 +251,7 @@ View the page source and look at the wrapper Weft rendered:
|
|
|
253
251
|
<div id="attendee-list-summer-bbq">
|
|
254
252
|
```
|
|
255
253
|
|
|
256
|
-
That DOM id was derived, not written: the class name, plus the value of the component's **first declared
|
|
254
|
+
That DOM id was derived, not written: the class name, plus the value of the component's **first declared param**. The convention matters — it's how updates land on the right element when several instances share a page — so declare the identifying param first. (A list of attendee *rows*, say, would want `param :name` first, or every row would collide on the same event-derived id.)
|
|
257
255
|
|
|
258
256
|
One more thing, and it's the heart of Weft. Your component isn't just markup inside the page — it's independently addressable:
|
|
259
257
|
|
|
@@ -271,32 +269,32 @@ Now the interactive part. Create `app/components/rsvp_form.rb`:
|
|
|
271
269
|
class RSVPForm < Weft::Component
|
|
272
270
|
builder_method :rsvp_form
|
|
273
271
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
272
|
+
param :event_id
|
|
273
|
+
param :name
|
|
274
|
+
param :answer
|
|
275
|
+
param :error_message
|
|
278
276
|
|
|
279
277
|
includes AttendeeList
|
|
280
278
|
|
|
281
|
-
performs :submit do |
|
|
282
|
-
event = EventStore.find(
|
|
283
|
-
name =
|
|
279
|
+
performs :submit do |params|
|
|
280
|
+
event = EventStore.find(params.event_id)
|
|
281
|
+
name = params.name.to_s.strip
|
|
284
282
|
raise Weft::Unprocessable, "Please tell us your name." if name.empty?
|
|
285
283
|
|
|
286
|
-
event.rsvps[name] =
|
|
284
|
+
event.rsvps[name] = params.answer
|
|
287
285
|
nil
|
|
288
286
|
end
|
|
289
287
|
|
|
290
|
-
recovers from: Weft::Unprocessable do |
|
|
288
|
+
recovers from: Weft::Unprocessable do |_params, error|
|
|
291
289
|
{ error_message: error.message }
|
|
292
290
|
end
|
|
293
291
|
|
|
294
292
|
def build(attributes = {})
|
|
295
293
|
super
|
|
296
294
|
h2 "RSVP"
|
|
297
|
-
para(
|
|
295
|
+
para(params.error_message, style: "color:#b91c1c") if params.error_message
|
|
298
296
|
form(action: :submit) do
|
|
299
|
-
input(type: "hidden", name: "event_id", value:
|
|
297
|
+
input(type: "hidden", name: "event_id", value: params.event_id)
|
|
300
298
|
label("Your name: ", for: "name")
|
|
301
299
|
input(type: "text", name: "name", id: "name")
|
|
302
300
|
label(" Coming? ", for: "answer")
|
|
@@ -312,15 +310,15 @@ end
|
|
|
312
310
|
Add it to `EventPage` above the attendee list:
|
|
313
311
|
|
|
314
312
|
```ruby
|
|
315
|
-
rsvp_form
|
|
316
|
-
attendee_list
|
|
313
|
+
rsvp_form
|
|
314
|
+
attendee_list
|
|
317
315
|
```
|
|
318
316
|
|
|
319
317
|
Restart, open Trivia Night, RSVP as yourself — **the attendee list updates without a page reload**, and the form clears. Then try submitting with a blank name: a red message appears in the form, and the list is untouched.
|
|
320
318
|
|
|
321
319
|
That's a lot from one class. Unpacking it:
|
|
322
320
|
|
|
323
|
-
**`performs :submit` declares a user action.** The block is the behavior: it receives the component's resolved
|
|
321
|
+
**`performs :submit` declares a user action.** The block is the behavior: it receives the component's resolved params, does its work, and whatever it returns directs what renders next — `nil` means "re-render me fresh" (our success path). Weft generates the route for the action; you never wrote one.
|
|
324
322
|
|
|
325
323
|
**`form(action: :submit)` wires the form to the action.** Look at the rendered HTML:
|
|
326
324
|
|
|
@@ -331,15 +329,15 @@ That's a lot from one class. Unpacking it:
|
|
|
331
329
|
|
|
332
330
|
The `hx-*` attributes make the form submit in place. The plain `action` and `method` attributes are there too, so the form still works with JavaScript disabled — it degrades to a normal POST.
|
|
333
331
|
|
|
334
|
-
**Form fields map to declared
|
|
332
|
+
**Form fields map to declared params, one to one.** The action block reads `params.name` and `params.answer` because the form has fields named `name` and `answer` *and* the component declares params of the same names. Both halves are needed: declared-but-not-a-field values don't travel (that's why `event_id` rides along as a hidden input — it's part of the component's identity, not something the user types), and field-but-not-declared values are ignored.
|
|
335
333
|
|
|
336
|
-
**Validation is a raise plus a recovery.** The action raises `Weft::Unprocessable`; the `recovers` declaration catches it, and its block returns extra
|
|
334
|
+
**Validation is a raise plus a recovery.** The action raises `Weft::Unprocessable`; the `recovers` declaration catches it, and its block returns extra params to merge into the re-render — here, `error_message`, which `build` displays when present. Note that `error_message` is itself a declared param: recovery data flows through the same schema as everything else. The response even carries a semantically-correct 422 status. See [Error handling](error-handling.md) for how far this system goes.
|
|
337
335
|
|
|
338
336
|
**`includes AttendeeList` updates the list in the same response.** Submitting the form changes data that *another* component displays. This declaration says: whenever RSVPForm responds to an action, render AttendeeList too, marked so it swaps into its own place in the page (by that derived DOM id — this is why the convention exists). One interaction, two regions updated, zero JavaScript.
|
|
339
337
|
|
|
340
338
|
## 8. Going live
|
|
341
339
|
|
|
342
|
-
The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the
|
|
340
|
+
The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the param:
|
|
343
341
|
|
|
344
342
|
```ruby
|
|
345
343
|
refreshes every: 10
|
|
@@ -359,13 +357,13 @@ Declared once on the class, the behavior is present in the initial page render *
|
|
|
359
357
|
|
|
360
358
|
You've built pages that route themselves, components that compose and self-address, a validated user action with out-of-band updates, and a live-polling list — the core of how Weft apps are put together.
|
|
361
359
|
|
|
362
|
-
**An exercise, if you're enjoying yourself:** add a "withdraw" button next to each attendee. You'll want a per-attendee component (careful which
|
|
360
|
+
**An exercise, if you're enjoying yourself:** add a "withdraw" button next to each attendee. You'll want a per-attendee component (careful which param you declare first — each row needs its own DOM id), and the `dismisses` verb, which removes a component from the page when its action succeeds. The [DSL reference](dsl.md#dismisses--remove-from-the-dom) has what you need.
|
|
363
361
|
|
|
364
362
|
**A finishing touch:** the events list living at `/events` leaves `/` as a 404. Give `EventsPage` an explicit home: `self.page_path = "/"`.
|
|
365
363
|
|
|
366
364
|
**The reference docs**, when you want the full picture:
|
|
367
365
|
|
|
368
|
-
- [The Weft DSL](dsl.md) — every verb (`transfers`, `pushes`, `dismisses`, `triggers`…), the element kwargs, and the interaction
|
|
366
|
+
- [The Weft DSL](dsl.md) — every verb (`transfers`, `pushes`, `dismisses`, `triggers`…), the element kwargs, and the interaction presets (tooltips, modals, lazy loading) this tutorial didn't touch.
|
|
369
367
|
- [Arbre: the HTML layer](arbre.md) — the HTML builder underneath every `build` method: its argument conventions, text handling, container patterns, and gotchas beyond `para`.
|
|
370
368
|
- [Routing](routing.md) — how paths derive, what's routable, collision detection.
|
|
371
369
|
- [Error handling](error-handling.md) — the error family, recovery chains, branding your error pages.
|
data/lib/weft/action.rb
CHANGED
|
@@ -47,6 +47,8 @@ module Weft
|
|
|
47
47
|
|
|
48
48
|
TRIGGER_VALUES = {
|
|
49
49
|
click: "click",
|
|
50
|
+
click_once: "click once",
|
|
51
|
+
change: "change",
|
|
50
52
|
hover: "mouseenter once",
|
|
51
53
|
visible: "revealed",
|
|
52
54
|
input: "input changed delay:300ms"
|
|
@@ -64,9 +66,9 @@ module Weft
|
|
|
64
66
|
path = route_path(component.class.resolved_component_path)
|
|
65
67
|
{
|
|
66
68
|
"hx-#{method}" => path,
|
|
67
|
-
"hx-target" => target || "##{component.
|
|
69
|
+
"hx-target" => target || "##{component.weft_dom_id}",
|
|
68
70
|
"hx-swap" => self.class.resolve_swap(swap),
|
|
69
|
-
"hx-vals" => component.
|
|
71
|
+
"hx-vals" => component.serializable_params.to_json
|
|
70
72
|
}
|
|
71
73
|
end
|
|
72
74
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weft/error"
|
|
4
|
+
require "weft/router"
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
# Zeitwerk-backed autoloading for application code, behind
|
|
8
|
+
# {Weft.configure_autoloading}. Each call builds one Zeitwerk loader over the
|
|
9
|
+
# given paths and eager-loads it immediately — Weft routes from the Registry,
|
|
10
|
+
# which populates via the `inherited` hook, so the classes must exist before
|
|
11
|
+
# the first request (lazy autoload alone would serve an empty route table).
|
|
12
|
+
#
|
|
13
|
+
# With reload: true, a per-request Router hook reloads constants, and
|
|
14
|
+
# Zeitwerk's on_unload callback evicts each outgoing class from the registry
|
|
15
|
+
# as it unloads — the push-model complement to {Registry#evict}, which
|
|
16
|
+
# hand-rolled reloaders call directly.
|
|
17
|
+
module Autoloading
|
|
18
|
+
class << self
|
|
19
|
+
# Loaders created so far, in creation order. @api private
|
|
20
|
+
def loaders
|
|
21
|
+
@loaders ||= []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def setup(paths:, inflections: {}, reload: false)
|
|
25
|
+
paths = Array(paths)
|
|
26
|
+
validate!(paths, inflections)
|
|
27
|
+
require "zeitwerk"
|
|
28
|
+
|
|
29
|
+
loader = build_loader(paths, inflections, reload)
|
|
30
|
+
loader.setup
|
|
31
|
+
loader.eager_load
|
|
32
|
+
install_reload_hook(loader) if reload
|
|
33
|
+
loaders << loader
|
|
34
|
+
loader
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_loader(paths, inflections, reload)
|
|
40
|
+
loader = Zeitwerk::Loader.new
|
|
41
|
+
paths.each { |dir| loader.push_dir(dir) }
|
|
42
|
+
loader.inflector.inflect(inflections) unless inflections.empty?
|
|
43
|
+
loader.enable_reloading if reload # must precede setup — Zeitwerk's contract
|
|
44
|
+
loader.on_unload { |_cpath, value, _abspath| Weft.registry.evict(value) }
|
|
45
|
+
loader
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate!(paths, inflections)
|
|
49
|
+
raise Weft::InvalidConfiguration, "configure_autoloading requires at least one path" if paths.empty?
|
|
50
|
+
return if inflections.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
|
|
51
|
+
|
|
52
|
+
raise Weft::InvalidConfiguration,
|
|
53
|
+
"configure_autoloading inflections must map file basenames to constant names " \
|
|
54
|
+
"(String => String), got #{inflections.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The dev-mode request hook: reload constants (evicting via on_unload as
|
|
58
|
+
# they unload), eager-load so everything re-registers, then rebind any
|
|
59
|
+
# configuration knob left holding a superseded class.
|
|
60
|
+
def install_reload_hook(loader)
|
|
61
|
+
Router.before do
|
|
62
|
+
loader.reload
|
|
63
|
+
loader.eager_load
|
|
64
|
+
Weft.configuration.refresh_stale_classes!
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|