@loadbare/app 0.7.2 → 0.7.4

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.
@@ -0,0 +1,1123 @@
1
+ # Loadbare/app and current front-end frameworks
2
+
3
+ > LLM-authored, not yet revised by a person. Drafted by Claude on
4
+ > 2026-09-16 against the working tree of `@loadbare/app` 0.7.3, which
5
+ > includes `lb-show`. Statements about other frameworks are from general
6
+ > knowledge of those projects as of 2026 and carry no per-claim sources.
7
+
8
+ This document states how `@loadbare/app` differs from the frameworks and
9
+ libraries most used for front-end development today. It repeats material
10
+ from [Theory](./theory.md), [TECHREF-1.0](./TECHREF-1.0.md),
11
+ [Prior art](./prior-art.md) and
12
+ [Assessing the accidental complexity claim](./analysis-accidental-complexity.md)
13
+ so that it can be read on its own. Where this document and
14
+ [Theory](./theory.md) disagree, Theory is authoritative.
15
+
16
+ Loadbare/app has not reached 1.0. The [Blockers](./TECHREF-1.0.md#blockers)
17
+ section of TECHREF-1.0 lists open decisions, and five of its sections are
18
+ marked `UNEDITED`. Anything below that describes Loadbare/app describes the
19
+ code as it stands.
20
+
21
+ ## Contents
22
+
23
+ 1. [Summary](#summary)
24
+ 2. [The comparison set](#the-comparison-set)
25
+ 3. [Stated goals and scope](#stated-goals-and-scope)
26
+ 4. [Where state lives](#where-state-lives)
27
+ 5. [Rendering](#rendering)
28
+ 6. [Templates and markup](#templates-and-markup)
29
+ 7. [Components and widgets](#components-and-widgets)
30
+ 8. [Data on the wire](#data-on-the-wire)
31
+ 9. [Mutations, forms and request state](#mutations-forms-and-request-state)
32
+ 10. [Routing and navigation](#routing-and-navigation)
33
+ 11. [Build, tooling and checking](#build-tooling-and-checking)
34
+ 12. [CSS](#css)
35
+ 13. [The server](#the-server)
36
+ 14. [Performance profile](#performance-profile)
37
+ 15. [Surface area](#surface-area)
38
+ 16. [Accessibility](#accessibility)
39
+ 17. [Testing](#testing)
40
+ 18. [Release history and churn](#release-history-and-churn)
41
+ 19. [What Loadbare/app does not do](#what-loadbareapp-does-not-do)
42
+ 20. [Requirements and fit](#requirements-and-fit)
43
+ 21. [Appendix A: Matrix](#appendix-a-matrix)
44
+ 22. [Appendix B: One page, three ways](#appendix-b-one-page-three-ways)
45
+ 23. [Appendix C: Glossary of nearest equivalents](#appendix-c-glossary-of-nearest-equivalents)
46
+
47
+ ## Summary
48
+
49
+ Loadbare/app is a framework for server-bound database applications. The
50
+ browser receives one HTML document containing every page as a `<template>`,
51
+ one script and one stylesheet. After that, only JSON rows cross the wire.
52
+ HTML attributes bind elements to named server queries, and one attribute,
53
+ `lb-action`, turns a click or a submit into a request. The server answers
54
+ every request with rows, sets of rows, or patches to sets of rows, and the
55
+ browser-side runtime, called the hub, writes those values into the elements
56
+ bound to them.
57
+
58
+ Compared with the client component frameworks (React, Vue, Angular, Svelte,
59
+ Solid), Loadbare/app has no component tree rendered at run time, no
60
+ client-side state, no virtual DOM or reactivity system, and no runtime
61
+ template expressions.
62
+
63
+ Compared with the server-integrated meta-frameworks (Next.js, Nuxt,
64
+ SvelteKit, React Router 7, Astro), Loadbare/app has no server rendering and
65
+ no hydration, ships no server of its own, and has no route parameters.
66
+
67
+ Compared with the hypermedia libraries (htmx, Hotwire Turbo, Unpoly),
68
+ Loadbare/app sends JSON rather than HTML after the first load, requires a
69
+ build step, and requires no endpoint design.
70
+
71
+ Compared with server-driven stateful UI (Phoenix LiveView, Laravel Livewire,
72
+ Blazor Server), Loadbare/app keeps no per-client process or component state
73
+ on the server, and uses ordinary HTTP requests rather than a persistent
74
+ connection.
75
+
76
+ | Group | Rendering happens | State lives | After first load, the wire carries |
77
+ |--------------------------|-----------------------|------------------------------|------------------------------------|
78
+ | Loadbare/app | Build time, once | Server database, and the DOM | JSON rows |
79
+ | Client component | Browser, on change | Browser memory | JSON (application-designed) |
80
+ | Meta-frameworks | Server, then browser | Server and browser memory | JSON, RSC payloads, or HTML |
81
+ | Hypermedia | Server, per request | Server, and the DOM | HTML |
82
+ | Server-driven stateful | Server, per event | Server memory per client | DOM diffs or HTML |
83
+
84
+ ## The comparison set
85
+
86
+ | Group | Members |
87
+ |--------------------------|--------------------------------------------------|
88
+ | Client component | React, Vue, Angular, Svelte, Solid |
89
+ | Meta-frameworks | Next.js, Nuxt, SvelteKit, React Router 7, Astro |
90
+ | Hypermedia | htmx, Hotwire (Turbo and Stimulus), Unpoly |
91
+ | Server-driven stateful | Phoenix LiveView, Laravel Livewire, Blazor Server|
92
+ | Sprinkles and components | Alpine.js, Lit |
93
+
94
+ React Router 7 is the continuation of Remix. TanStack Query appears below
95
+ where client-side server-state caching is discussed, because React
96
+ applications commonly pair it with a data API.
97
+
98
+ ## Stated goals and scope
99
+
100
+ Theory states two goals, in order.
101
+
102
+ 1. Performance. A full round trip, from user action through durable
103
+ persistence to final paint, should complete in a median of 300ms.
104
+ Assuming roughly 200ms of wire time leaves 100ms for the database, the
105
+ application server and the browser together, so the framework's own
106
+ processing time in the browser and on the server should approach zero.
107
+ 2. Developer efficiency, defined as reducing accidental complexity (Brooks):
108
+ work demanded by the tools rather than by the problem.
109
+
110
+ The scope follows from a hypothesis stated in Theory: in a data-oriented
111
+ application, the set of DOM changes needed is closed and knowable, so a
112
+ framework optimized for that set does not need the machinery required to
113
+ support arbitrary DOM updates.
114
+
115
+ Loadbare/app restricts itself to applications whose data can be sent as rows
116
+ and sets of rows. Theory states that the claims about accidental complexity
117
+ are most likely to hold "when the existing stack can support an application
118
+ that wants data to be rows and tables, or easily mapped into them and out of
119
+ them, and the desired affordances can be easily mapped to the standard set of
120
+ operations against that data."
121
+
122
+ Each of the other projects in the comparison set presents itself as a
123
+ general-purpose tool for building web user interfaces, and none restricts the
124
+ shape of the data an application works with.
125
+
126
+ ## Where state lives
127
+
128
+ ### Loadbare/app
129
+
130
+ The hub holds no application data. There is no store, no signal, no
131
+ observable, and no client cache of query results.
132
+
133
+ - A value that has landed exists in the DOM: as `textContent` or `value`, and
134
+ as the `lb-value` attribute.
135
+ - A list exists as the DOM rows the hub cloned, each stamped with
136
+ `lb-key-value`.
137
+ - An element hidden by `lb-show` exists inside a `<template>` standing where
138
+ it stood.
139
+ - The hub keeps two pieces of state of its own: the name of the current page,
140
+ and, until an insert completes, a record of which controls it read.
141
+
142
+ The server is the source of truth for everything else, including view state.
143
+ A query takes no argument from the browser; its only input is `ctx`, which
144
+ the application builds from the Express request. Which record a page shows,
145
+ or which step of a wizard is current, is held on the server where `ctx`
146
+ reaches it. A reload runs the page's queries again and shows the same state.
147
+
148
+ A URL names a page and nothing finer. Whether a page URL may carry
149
+ parameters is an open blocker in TECHREF-1.0.
150
+
151
+ ### Elsewhere
152
+
153
+ | Project | Where application state is kept |
154
+ |-------------------|-----------------------------------------------------------------------------------------|
155
+ | React | Component state (`useState`, `useReducer`) in the component tree; context; external stores such as Redux or Zustand; server-state caches such as TanStack Query or SWR |
156
+ | Vue | Reactive proxies (`ref`, `reactive`); Pinia stores |
157
+ | Angular | Signals, services provided through dependency injection, RxJS observables |
158
+ | Svelte | Runes (`$state`, `$derived`); stores |
159
+ | Solid | Signals and stores |
160
+ | Next.js | Server Components read data on the server; Client Components hold React state; the client router caches RSC payloads |
161
+ | React Router 7 | Loader data held by the router in the browser; component state |
162
+ | SvelteKit, Nuxt | Load function or `useFetch` data held in the browser; component state |
163
+ | htmx, Turbo | The server, and the HTML currently in the DOM. Turbo keeps a snapshot cache of visited pages for back navigation |
164
+ | Phoenix LiveView | Socket assigns in a server process per connected client |
165
+ | Livewire | Component properties serialized into a snapshot in the page and sent back with each request |
166
+ | Blazor Server | Component state in server memory, per circuit |
167
+ | Alpine.js | Reactive objects declared with `x-data` |
168
+
169
+ ## Rendering
170
+
171
+ ### Loadbare/app
172
+
173
+ HTML is produced once, by the builder. Widget expansion, build-time
174
+ parameters, slots and named templates are resolved at build time, and every
175
+ page is placed in the one document as `<template lb-page="name">`. The
176
+ browser parses that document once.
177
+
178
+ At run time the hub performs a closed set of operations:
179
+
180
+ | Trigger | Operation |
181
+ |----------------------------|----------------------------------------------------------------------------|
182
+ | Navigation | Clone the page's `<template>` into `<main>` |
183
+ | A value for a native element | Set `textContent`, and set `lb-value` |
184
+ | A value for a form control | Set `value`, and set `lb-value` |
185
+ | A value for a custom element | Set `lb-value` |
186
+ | A new key in a list | Clone the row template, stamp `lb-key-value`, fill it, insert it |
187
+ | A whole list | Place every row in the order given, remove rows whose keys did not arrive |
188
+ | A patch | Upsert the rows named, remove the keys named, leave other rows in place |
189
+ | After any list result | Stamp `lb-row-count` |
190
+ | An `lb-show` column | Move the element into, or out of, a `<template lb-show>` |
191
+ | A request starts or ends | Set or remove `lb-pending` and `aria-busy`; set `lb-error` on failure |
192
+ | An insert succeeds | Reset the controls it gathered to their defaults |
193
+
194
+ The hub finds targets with `querySelectorAll` on the attributes written in
195
+ the HTML. No tree is compared with a previous tree. A response names what
196
+ changed, so the hub does not determine what changed. Between interactions
197
+ the hub runs no timers, observers or polling loops.
198
+
199
+ There is no server-side rendering and no hydration. The server returns the
200
+ values its queries return, serialized as JSON.
201
+
202
+ ### Elsewhere
203
+
204
+ | Project | How the DOM is produced and updated |
205
+ |------------------|---------------------------------------------------------------------------------------|
206
+ | React | Components re-run on state change and return an element tree, which is reconciled against the previous tree; keys identify list items |
207
+ | Vue | Templates compile to render functions producing a virtual DOM; reactivity tracks which components depend on which state |
208
+ | Angular | Templates compile to instructions; change detection checks bindings, and signals mark views that need updating |
209
+ | Svelte | The compiler emits code that updates the DOM nodes bound to changed state |
210
+ | Solid | JSX compiles to DOM creation code; components run once; signals update bound nodes |
211
+ | Next.js, Nuxt, SvelteKit, React Router 7 | HTML is rendered on the server per request or at build time, then hydrated in the browser (in Next.js, the Client Components), after which the client framework renders |
212
+ | Astro | HTML rendered at build or request time; interactive islands are hydrated per `client:*` directive |
213
+ | htmx | The server renders HTML fragments per request; htmx swaps them into a target |
214
+ | Turbo | The server renders pages; Turbo replaces the body, a `<turbo-frame>`, or applies Turbo Stream actions; page refreshes can morph |
215
+ | Phoenix LiveView | The server re-renders changed parts of a template and sends diffs over a WebSocket; the client patches the DOM |
216
+ | Livewire | The server re-renders component HTML; the client morphs the DOM |
217
+ | Blazor Server | The server renders the component tree, computes a diff, and sends it over SignalR |
218
+ | Alpine.js | Directives on server-rendered HTML update bound nodes from reactive data |
219
+ | Lit | Tagged template literals update the dynamic parts of a template, by default inside a shadow root |
220
+
221
+ ## Templates and markup
222
+
223
+ ### Loadbare/app
224
+
225
+ The HTML written in `.html` files is the HTML that ships, after expansion.
226
+ Attribute values in the `lb-` namespace are names: a query name, a column
227
+ name, or an action name. No attribute value is an expression.
228
+
229
+ There are two kinds of placeholder, and they run at different times.
230
+
231
+ - Build time. A widget definition writes `{{name}}` or `{{name|default}}` as
232
+ a whole attribute value or a whole text node. The tag supplies it with an
233
+ `exp-name` attribute. An unsupplied placeholder with no default drops the
234
+ attribute. The value becomes permanent in the shipped HTML.
235
+ - Run time. Data lands through `lb-cell`, `lb-list` with a keyed
236
+ `<template>`, and `lb-show`. There is no run-time interpolation into
237
+ attribute values or text.
238
+
239
+ Interpolation inside a longer string, such as `title="Hello {{name}}"`, is
240
+ listed as decided against in the [Roadmap](./roadmap.md#decided-against).
241
+
242
+ | Task | Loadbare/app | React (JSX) | Vue | Angular | Svelte | Alpine.js |
243
+ |-------------------|-----------------------------------------------|--------------------------------------|--------------------------------------|---------------------------------------|-------------------------------|--------------------------------------------|
244
+ | Show a value | `<span lb-cell="name">` | `{row.name}` | `{{ row.name }}` | `{{ row.name }}` | `{row.name}` | `<span x-text="row.name">` |
245
+ | Keyed list | `<template lb-key="id">` inside `lb-list` | `rows.map(r => <li key={r.id}>)` | `<li v-for="r in rows" :key="r.id">` | `@for (r of rows; track r.id)` | `{#each rows as r (r.id)}` | `<template x-for="r in rows" :key="r.id">` |
246
+ | Condition | `lb-show="column"` | `{cond && <X />}` | `v-if`, `v-show` | `@if (cond) { }` | `{#if cond}` | `x-if`, `x-show` |
247
+ | Expression in markup | Not supported | Any JavaScript expression | JavaScript expressions | Template expressions | JavaScript expressions | JavaScript expressions |
248
+ | Build-time constant | `exp-label="Name"` into `{{label}}` | A prop | A prop | An input | A prop | Not applicable |
249
+
250
+ In htmx and Turbo, markup for data is produced by whatever server template
251
+ language the application uses. LiveView uses HEEx, with `{@value}`
252
+ interpolation, `:for` and `:if`.
253
+
254
+ ### Conditions
255
+
256
+ `lb-show` names a column. `null` and `false` are off; every other value is
257
+ on, including the string `"false"`. An element that is off is moved into a
258
+ `<template lb-show="column">` standing where it stood, and moved back when the
259
+ column turns on. It is not rendered, focused, clicked, announced or gathered.
260
+ Values keep landing inside the template, so the element returns current. The
261
+ builder ships every `lb-show` element already inside its template.
262
+
263
+ | Framework | A false branch | State kept | Updated while hidden |
264
+ |----------------------------------|-----------------------------------------------------------------------|------------|----------------------|
265
+ | Loadbare/app `lb-show` | Moved into a `<template>` in place | yes | yes |
266
+ | Alpine `<template x-if>` | Destroyed and removed; recloned on show | no | no |
267
+ | Knockout `if` | Contents re-rendered on show | no | no |
268
+ | Angular `@if` / `*ngIf` | View destroyed unless detached and reinserted by hand | if detached | no |
269
+ | Aurelia `if.bind` | Removed; views cached and reused by default | yes | not documented |
270
+ | Vue `v-if` in `<KeepAlive>` | Detached, not unmounted | yes | no |
271
+ | Lit `cache()` | DOM kept in a `DocumentFragment` | yes | on swap-in |
272
+ | Polymer `dom-if` | `display: none` | yes | yes |
273
+ | React `<Activity mode="hidden">` | `display: none`, Effects destroyed | yes | yes, at lower priority |
274
+
275
+ Sources for the rows other than Loadbare/app are in
276
+ [Prior art](./prior-art.md#conditional-rendering-elsewhere).
277
+
278
+ A condition that is not data, such as an open menu, has no column. The
279
+ reference directs it to `<details>`, a stylesheet, or a widget.
280
+
281
+ ## Components and widgets
282
+
283
+ ### Loadbare/app
284
+
285
+ A widget is a custom element defined by one or two files sharing its tag
286
+ name, found by the builder anywhere in the source tree:
287
+
288
+ | File | Holds |
289
+ |-------------------------|-----------------------------------------------------|
290
+ | `<tag-name>.html` | Markup the tag expands into, at build time |
291
+ | `<tag-name>.browser.ts` | A class registered with `customElements.define` |
292
+
293
+ A tag with neither file is a build error. A module named `<tag-name>.ts`
294
+ without `.browser` is not bundled, and the build error names it.
295
+
296
+ - Light DOM throughout. Loadbare/app uses no shadow DOM.
297
+ - No base class. A widget extends `HTMLElement`.
298
+ - No props at run time. Build-time parameters arrive as `exp-` attributes.
299
+ Data arrives as the `lb-value` attribute, which the widget observes through
300
+ `attributeChangedCallback`. The browser calls that callback for an
301
+ attribute already present at upgrade, so first render and refresh are one
302
+ code path.
303
+ - Composition at build time. `lb-slot` marks where authored content goes;
304
+ `lb-template` names additional destinations filled by
305
+ `<template lb-template="name">`. A definition may use other widgets; a
306
+ cycle is a build error.
307
+ - A widget sends a request by dispatching a bubbling `lb-request`
308
+ `CustomEvent`. The hub adds the scope (`list` or `row`, `key`, `cell`). An
309
+ ancestor may stop the event.
310
+ - A list widget may implement `lbPlaceRow(el, row, template)` to decide where
311
+ a row goes, and `lbRowsLanded()` to build scaffolding such as group
312
+ headings. The hub does the cloning, matching, removal and counting.
313
+ - Method names beginning with `lb` on a custom element are reserved.
314
+
315
+ Widget libraries are npm packages listed in `imports.ts`. The builder scans
316
+ each listed package for `<tag>.html` and `<tag>.browser.js` files, in the
317
+ directory named by `loadbare.widgets` in its `package.json`, or the whole
318
+ package when that key is absent. Only tags used in the chrome or a page are
319
+ bundled. `@loadbare/widgets` ships `lb-input`, `lb-select`, `lb-options`,
320
+ `lb-table`, `lb-picker` and `lb-unknown-page`.
321
+
322
+ ### Using web components and other libraries
323
+
324
+ The following follow from the builder and hub code.
325
+
326
+ - A third-party custom element used in markup must have a script or a
327
+ definition in some origin, or the build fails. A package that does not name
328
+ its files `<tag>.browser.js` can be used by writing `<tag>.browser.ts` in the
329
+ application's own tree that imports the package's module.
330
+ - A `.browser.ts` file is bundled by esbuild and may import any package
331
+ esbuild resolves. A custom element may mount another framework's component
332
+ tree inside itself.
333
+ - The hub does not turn a click on a hyphenated element into a request. A
334
+ third-party element carrying `lb-action` sends nothing unless it dispatches
335
+ `lb-request` itself.
336
+ - A bound value lands on a third-party element as `lb-value`. The element
337
+ displays it only if it observes that attribute.
338
+ - Gathering for `lb-row-insert` and `lb-row-update` reads `<input>`, `<select>`
339
+ and `<textarea>` elements found with `querySelectorAll`, which does not
340
+ enter a shadow root.
341
+
342
+ ### Elsewhere
343
+
344
+ | Project | Unit | Inputs | Children | Style scope |
345
+ |-------------|------------------------------------|-------------------------------|-----------------------------------|-----------------------------|
346
+ | React | Function returning JSX | Props | `children` prop, render props | None built in |
347
+ | Vue | Single-file component (`.vue`) | Props, emits | Default and named slots | `<style scoped>` available |
348
+ | Angular | Class with `@Component` decorator | Inputs, outputs, signals | Content projection (`ng-content`) | Emulated encapsulation by default |
349
+ | Svelte | `.svelte` file | `$props()` | Snippets | Scoped by default |
350
+ | Solid | Function returning JSX, run once | Props | `children` | None built in |
351
+ | Lit | `LitElement` subclass | Reactive properties, attributes | `<slot>` in shadow DOM | Shadow DOM by default |
352
+ | Stimulus | Controller class attached by `data-controller` | Values, targets | Server-rendered HTML | None |
353
+ | LiveView | Function components and LiveComponents | Assigns | Slots | None built in |
354
+
355
+ React 19 added full support for custom elements, including setting
356
+ properties, so web components can be used in React applications directly.
357
+ Component libraries written for one framework (for example MUI for React or
358
+ Vuetify for Vue) are not usable in another without a wrapper.
359
+
360
+ ## Data on the wire
361
+
362
+ ### Loadbare/app
363
+
364
+ Every request from the hub is `POST /lb?page=<name>` with a JSON body.
365
+
366
+ - An empty body asks for the page's whole query set. The server runs the
367
+ page's `onPageEnter`, then every query the page declares.
368
+ - A body with an `action` runs that action and its refresh set.
369
+ - A request without an action answers 400. A `run` that throws answers 500.
370
+
371
+ Every response has one shape: an object keyed by query name.
372
+
373
+ | Value | Means |
374
+ |------------------|-----------------------------------------------------|
375
+ | An object | One row, for a name declared with `row()` |
376
+ | An array | The whole set, and its order, for a `list()` |
377
+ | `{ rows, drop }` | A patch to a `list()`: upsert these, remove these keys |
378
+
379
+ A cell holds one value. A set of values is a list of its own. A row never
380
+ contains rows. Master-detail is a row and a list answered under two names.
381
+ Many masters with their details is one list of joined rows, grouped for
382
+ display by a widget's `lbPlaceRow`.
383
+
384
+ The application designs no endpoints, routes or serialization format. The
385
+ names in the markup are the keys in the page's `.queries.ts` and
386
+ `.requests.ts` files. The browser can reach only the queries, actions and
387
+ CRUD operations a page declares; any other name answers `{}` and logs a
388
+ server warning.
389
+
390
+ Values are not converted. The hub hands each value to the browser as the
391
+ query produced it. Formatting a date or an amount is done in the query.
392
+ Data types are an open blocker in TECHREF-1.0.
393
+
394
+ ### Elsewhere
395
+
396
+ | Project | Data requests after first load |
397
+ |-------------------|---------------------------------------------------------------------------------|
398
+ | React, Vue, Angular, Svelte, Solid (client only) | Whatever the application designs: REST, GraphQL, tRPC, or other |
399
+ | Next.js | Server Functions as POST requests identified by a `Next-Action` header; navigations fetch RSC payloads |
400
+ | React Router 7 | Loader data fetched by `.data` requests; actions posted to the route URL |
401
+ | SvelteKit | Load data from `__data.json`; form actions posted to `?/actionName` |
402
+ | Nuxt | Server routes under `server/api` (Nitro), fetched with `useFetch` or `$fetch` |
403
+ | htmx | Any HTTP verb to any URL named in `hx-get`, `hx-post` and related attributes; HTML response |
404
+ | Turbo | Ordinary page and form requests; HTML responses; Turbo Stream responses |
405
+ | Phoenix LiveView | Events and diffs over a WebSocket |
406
+ | Livewire | POST requests carrying component snapshots and calls |
407
+ | Blazor Server | SignalR messages |
408
+
409
+ ## Mutations, forms and request state
410
+
411
+ ### Loadbare/app
412
+
413
+ `lb-action` names what an interaction asks for. A value beginning with `lb-`
414
+ is one of four reserved operations; any other value is an action the page
415
+ declares.
416
+
417
+ | `lb-action` | Written on | Carries | Server key under `crud` |
418
+ |------------------|---------------------------------------------|---------------------------------|-------------------------|
419
+ | `lb-row-insert` | A `<form>`, or a button in a `<tr>`, in a list | `list`, `values` | `rowInsert` |
420
+ | `lb-row-update` | A `<form>` or a button in a live row | `list`, `key`, `values` | `rowUpdate` |
421
+ | `lb-row-delete` | Anything inside a live row | `list`, `key` | `rowDelete` |
422
+ | `lb-cell-change` | A widget wrapping one control | `list`, `key`, `cell`, `value` | `cellChange` |
423
+ | Any other name | Any element | Scope in effect, and a widget's `value` | Under `actions` |
424
+
425
+ - Position comes from the document. `list` or `row` comes from the nearest
426
+ ancestor scope, `key` from the nearest live row, `cell` from the element's
427
+ own `lb-cell`. The author writes the action and not the position.
428
+ - Values are gathered from the nearest `<form>`, `<tr>` or live row around
429
+ the element, the way a button submits its form owner.
430
+ - A native element sends on click, a form on submit. A widget sends its own.
431
+ - A declared action carries no argument list.
432
+ - A single-row scope is read-only for the four operations, because each needs
433
+ a key and a key exists only on a live row in a list.
434
+
435
+ On the server, each entry has `run` and `refresh`. `run` does the work and
436
+ may return results, such as a `patch()`. `refresh` names queries to run
437
+ again. What `run` returns is laid over the refreshed results.
438
+
439
+ The hub stamps the dispatching element:
440
+
441
+ | Attribute | Means |
442
+ |----------------|------------------------------------------------------------|
443
+ | `lb-pending` | The round trip is in flight; `aria-busy="true"` is set with it |
444
+ | `lb-error` | The last round trip from this element failed |
445
+
446
+ A native button or form performed again while it carries `lb-pending` is
447
+ ignored. A widget is not held back. A round trip has a ten-second deadline,
448
+ after which it is aborted and treated as a failure. `lb-error` records that
449
+ a round trip failed, and carries no message.
450
+
451
+ After a successful `lb-row-insert`, the hub resets each control it gathered
452
+ to its default, as `form.reset()` does, unless the control was edited during
453
+ the round trip. A failed insert resets nothing.
454
+
455
+ The browser displays only values the server returned. There is no
456
+ optimistic update. Per-keystroke validation is an open question in the
457
+ roadmap.
458
+
459
+ ### Elsewhere
460
+
461
+ | Project | Sending a mutation | Refreshing afterward | Pending state | Optimistic update |
462
+ |------------------|------------------------------------------------------|-------------------------------------------------------------|----------------------------------------|---------------------------|
463
+ | React | `<form action={fn}>`, event handlers | Application code | `useActionState`, `useFormStatus`, `useTransition` | `useOptimistic` |
464
+ | TanStack Query | `useMutation` | `invalidateQueries` by query key | `isPending` | `onMutate` |
465
+ | Next.js | Server Functions | `revalidatePath`, `revalidateTag` in the function | React hooks | `useOptimistic` |
466
+ | React Router 7 | `<Form>`, `useFetcher`, route `action` | Loaders on the page revalidate by default; `shouldRevalidate` opts out | `useNavigation`, `fetcher.state` | Application code from pending form data |
467
+ | SvelteKit | Form actions in `+page.server.ts`, `use:enhance` | `use:enhance` reruns load functions by default; `invalidate` for targeted reruns | Application code in `use:enhance` | Application code |
468
+ | Angular | Event bindings, `HttpClient` | Application code | Application code | Application code |
469
+ | htmx | `hx-post`, `hx-put`, `hx-delete` on any element | The HTML response; `hx-swap-oob`; `HX-Trigger` response header | `htmx-request` class, `hx-indicator`, `hx-disabled-elt` | Not built in |
470
+ | Turbo | Forms; a successful non-GET submission redirects | Re-render after redirect; Turbo Streams | `aria-busy` on the form, `data-turbo-submits-with` | Not built in |
471
+ | Phoenix LiveView | `phx-click`, `phx-submit`, `phx-change` | Assign changes re-render | `phx-click-loading` and related classes, `phx-disable-with` | `JS` commands |
472
+ | Livewire | `wire:click`, `wire:submit`, `wire:model` | Component re-render | `wire:loading` | Not built in |
473
+
474
+ ## Routing and navigation
475
+
476
+ ### Loadbare/app
477
+
478
+ - A page is `<name>.page.html`, found anywhere under `--src`. The directory
479
+ carries no meaning; page names are unique across the application.
480
+ - The path `/members` names the page `members`. The path `/` names `index`.
481
+ - There are no route parameters, nested routes or per-page layouts. The
482
+ chrome, `chrome.html`, is the one document surrounding every page.
483
+ - An anchor carrying `lb-nav-link` navigates inside the application: the hub
484
+ calls `history.pushState`, clones the page template into `<main>`, then
485
+ fetches the page's data. An anchor without the attribute is an ordinary
486
+ link. `popstate` navigates back and forward.
487
+ - Navigation fetches no HTML and no code. Every page's markup is already in
488
+ the document.
489
+ - The server answers every GET with the same `app.html` and status 200. A
490
+ path that names no page is detected in the browser, which opens a
491
+ `<dialog lb-unknown-page>` if the chrome has one.
492
+ - The hub publishes its own row, `lb-navigation`, with `page-uri` and
493
+ `page-label` (the text of the matching `lb-nav-link` anchor), bound like
494
+ any server row.
495
+ - The hub reads `location.pathname` only. A query string does not reach the
496
+ server.
497
+ - The application must be served from the origin root. The hub reaches
498
+ `/lb`, `/client.js` and `/app.css` by absolute path.
499
+ - The hub does not change `document.title`, move focus, or restore scroll
500
+ position on navigation.
501
+
502
+ ### Elsewhere
503
+
504
+ | Project | Routes declared by | Parameters | Layouts | Unknown path |
505
+ |------------------|-----------------------------------------------------|-------------------------------|-----------------------------|------------------------|
506
+ | Next.js (App Router) | Directories under `app/` with `page.tsx` | `[id]`, `[...slug]` segments | Nested `layout.tsx` | `not-found.tsx`, status 404 from the server |
507
+ | Nuxt | Files under `pages/` | `[id]` segments | `layouts/`, nested routes | Error page, status 404 |
508
+ | SvelteKit | Directories under `src/routes/` with `+page.svelte` | `[id]` segments | Nested `+layout.svelte` | `+error.svelte`, status 404 |
509
+ | React Router 7 | Route config or file conventions | `:id` segments | Nested routes with `<Outlet>` | Catch-all route |
510
+ | Angular | `Routes` array | `:id` segments | Nested router outlets | `**` wildcard route |
511
+ | Vue Router | Routes array | `:id` segments | Nested `<RouterView>` | Catch-all route |
512
+ | Phoenix LiveView | `live` routes in the router | `:id` segments, `handle_params` | Root and app layouts | Status 404 from the server |
513
+ | htmx, Turbo | Server routes | Server routes | Server templates | Server status |
514
+
515
+ A client-routed single-page application served by a catch-all route also
516
+ answers 200 for an unknown path, as Loadbare/app does.
517
+
518
+ ## Build, tooling and checking
519
+
520
+ ### Loadbare/app
521
+
522
+ ```
523
+ loadbare-app-build [--src src] [--out dist] [--watch] [--minify]
524
+ ```
525
+
526
+ - Four flags. No configuration file. An open blocker proposes one.
527
+ - Files are classified by name: `chrome.html`, `*.page.html`,
528
+ `*.queries.ts`, `*.requests.ts`, `imports.ts`, `*.css`, `<tag>.html`,
529
+ `<tag>.browser.ts`.
530
+ - Origins are scanned in order: `@loadbare/app` itself (which supplies
531
+ `lb-hub`), each package in `imports.ts`, then `--src`. A later origin
532
+ overrides an earlier one for the same tag.
533
+ - The client bundle is built by esbuild from a generated entry that imports
534
+ the script of every custom element left in the assembled document. A
535
+ widget script whose tag is never used is never imported.
536
+ - Outputs: `app.html`, `client.js`, `app.css` when there is a stylesheet,
537
+ `pages.ts` when there is page data, and `client-entry.ts`.
538
+ - `--watch` rebuilds on changes to `.html`, `.ts` and `.css` files. There is
539
+ no dev server, no hot module replacement and no browser reload.
540
+ - `app.html` is formatted with Prettier and never minified.
541
+
542
+ Checked at build time, as errors:
543
+
544
+ - a tag with neither a definition nor a script
545
+ - a module named for a tag without `.browser`
546
+ - two files claiming one tag in one origin
547
+ - a cycle among definitions
548
+ - an `exp-` attribute the definition does not declare, and a placeholder name
549
+ containing a capital
550
+ - slot and template errors: two slots, two templates of one name, a template
551
+ naming a missing destination, content in a definition with no slot
552
+ - `lb-cell` outside every scope, `lb-key` on anything but a list's row
553
+ template, `lb-list` and `lb-row` on one element
554
+ - `lb-show` on a `<template>`, on a row template's root, or with no row
555
+ around it
556
+ - `lb-unknown-page` on anything but a `<dialog>` inside `<lb-hub>`
557
+ - a `.queries.ts` or `.requests.ts` with no matching page
558
+
559
+ Checked when the server starts: a query or action name beginning with `lb-`.
560
+
561
+ Not checked at build time: that an `lb-list` or `lb-row` name matches a
562
+ declared query, that an `lb-action` matches a declared action or CRUD entry,
563
+ that an `lb-` attribute is one Loadbare defines, that the chrome contains one
564
+ `<lb-hub>` and one empty `<main>`. The first two are a roadmap item; the
565
+ last two are listed under the builder blockers. A language server is a
566
+ roadmap item.
567
+
568
+ ### Elsewhere
569
+
570
+ | Project | Build and dev tooling | Template type checking | Editor tooling |
571
+ |------------------|---------------------------------------------------------|-----------------------------------------------|---------------------------------|
572
+ | React | Chosen by the application or its framework; Create React App was deprecated in February 2025 | TypeScript checks JSX | TypeScript language service |
573
+ | Next.js | Turbopack or webpack; dev server with Fast Refresh | TypeScript; typed routes | TypeScript plugin |
574
+ | Vue, Nuxt | Vite; dev server with HMR | `vue-tsc` | Vue language tools (Volar) |
575
+ | Angular | Angular CLI (esbuild, Vite dev server) | Strict template type checking in the compiler | Angular Language Service |
576
+ | Svelte, SvelteKit| Vite; dev server with HMR | `svelte-check` | Svelte language server |
577
+ | Solid | Vite | TypeScript checks JSX | TypeScript language service |
578
+ | React Router 7 | Vite | TypeScript; generated route types | TypeScript language service |
579
+ | Astro | Vite | `astro check` | Astro language tools |
580
+ | htmx, Alpine.js | None required; a script tag | None | Community extensions |
581
+ | Hotwire (Rails) | Import maps or a JavaScript bundler | None for Turbo attributes | Community extensions |
582
+ | Phoenix | esbuild and Tailwind through Mix | Compile-time warnings for declared component attributes | Elixir language server |
583
+
584
+ ## CSS
585
+
586
+ ### Loadbare/app
587
+
588
+ The builder concatenates every `.css` file from every origin into `app.css`.
589
+ Nothing is scoped, renamed, added or removed.
590
+
591
+ - Order: each package in `imports.ts`, in first-mention order, then the
592
+ application's own `--src`. Within an origin, files sort by filename, with
593
+ the full path breaking a tie. Directories do not affect the order.
594
+ - Stylesheets are paired with nothing. Every stylesheet in every listed
595
+ package ships whether or not its widgets are used. Pairing a stylesheet
596
+ with a tag is a roadmap item.
597
+ - Light DOM means every selector reaches every element, in the chrome and in
598
+ widgets.
599
+ - The hub stamps attributes a stylesheet can select on: `lb-value`,
600
+ `lb-pending`, `lb-error`, `lb-row-count`. An empty list is styled with
601
+ `[lb-row-count="0"]`.
602
+
603
+ ### Elsewhere
604
+
605
+ | Project | Built-in scoping |
606
+ |-------------|---------------------------------------------------------------------------|
607
+ | React | None; applications choose CSS Modules, CSS-in-JS, Tailwind, or global CSS |
608
+ | Next.js | CSS Modules, global CSS, Sass and Tailwind supported by the build |
609
+ | Vue | `<style scoped>` adds a data attribute per component; CSS Modules |
610
+ | Angular | Emulated encapsulation adds attributes per component by default; shadow DOM and none available |
611
+ | Svelte | Component styles scoped by a generated class by default |
612
+ | Lit | Shadow DOM scoping by default |
613
+ | htmx, Turbo, Alpine.js | None |
614
+ | Phoenix | None built in; Tailwind configured by default in new projects |
615
+
616
+ ## The server
617
+
618
+ ### Loadbare/app
619
+
620
+ Loadbare/app ships no server. It ships:
621
+
622
+ - `createHub(pages)`, an engine with no HTTP code, in `@loadbare/app/server`
623
+ - `hubRoutes(hub, contextFor)`, an Express 5 router, in
624
+ `@loadbare/app/express`. Express is an optional peer dependency.
625
+ - `pages.ts`, generated by the builder, which imports every page's queries
626
+ and requests and exports `hub`
627
+
628
+ The application's server handles four things:
629
+
630
+ ```ts
631
+ app.use(hubRoutes(hub, contextFor));
632
+ app.get("/client.js", (_req, res) => res.sendFile(path.join(DIST, "client.js")));
633
+ app.get("/app.css", (_req, res) => res.sendFile(path.join(DIST, "app.css")));
634
+ app.get(/.*/, (_req, res) => res.sendFile(path.join(DIST, "app.html")));
635
+ ```
636
+
637
+ These lines do not change as pages are added.
638
+
639
+ `contextFor(req)` returns `ctx`, which Loadbare passes to every query and
640
+ request and never reads. Its type is declared by the application through
641
+ TypeScript declaration merging on `HubContext`. Authentication, sessions,
642
+ logging, database access, and any ORM or business-logic layer are the
643
+ application's, placed in Express middleware and in `ctx`.
644
+
645
+ A page's server half:
646
+
647
+ | File | Exports | Contents |
648
+ |----------------------|------------|--------------------------------------------------------------|
649
+ | `<name>.queries.ts` | `queries` | Named queries, each built with `row()` or `list()` |
650
+ | `<name>.requests.ts` | `requests` | `onPageEnter`, `actions` by name, `crud` by list name |
651
+
652
+ A query's signature is `(ctx) => Row` or `(ctx) => Row[]`. An answer whose
653
+ shape disagrees with its declaration is dropped with a warning.
654
+
655
+ The data channel carries no CSRF token. Where an options argument for
656
+ hooks, transaction wrapping, error handling and CSRF would go is an open
657
+ blocker. Serving the static files from a separate origin is a roadmap item.
658
+
659
+ ### Elsewhere
660
+
661
+ | Project | Server |
662
+ |------------------|------------------------------------------------------------------------------------|
663
+ | React, Vue, Angular, Svelte, Solid (client only) | None; the application supplies an API server |
664
+ | Next.js | Next.js server, or adapters for hosting platforms; Middleware or Proxy; Route Handlers |
665
+ | Nuxt | Nitro server with presets for hosting platforms |
666
+ | SvelteKit | Adapters (Node, static, platform-specific); hooks in `hooks.server.ts` |
667
+ | React Router 7 | Request handler with adapters for Node, Express and platforms |
668
+ | Astro | Static output, or server adapters |
669
+ | htmx | Any server that returns HTML |
670
+ | Hotwire | Any server; integrated with Rails |
671
+ | Phoenix LiveView | Phoenix |
672
+ | Livewire | Laravel |
673
+ | Blazor Server | ASP.NET Core |
674
+
675
+ ## Performance profile
676
+
677
+ ### Loadbare/app
678
+
679
+ First load of any path:
680
+
681
+ | Request | Returns |
682
+ |--------------------------|--------------------------------------------|
683
+ | `GET /<path>` | `app.html`: the chrome and every page as a `<template>` |
684
+ | `GET /client.js` | The hub and every used widget, one bundle |
685
+ | `GET /app.css` | Every stylesheet, one file |
686
+ | `POST /lb?page=<name>` | The page's query results as JSON |
687
+
688
+ The three static files are fixed at release and can be compressed, cached,
689
+ and served from a CDN. There are no per-route bundles, lazy chunks or module
690
+ waterfalls. The page template is cloned into `<main>` before its data
691
+ request is sent. An optional `<body hidden>` in the chrome keeps the
692
+ document hidden until the first page is in place.
693
+
694
+ Measured on this working tree:
695
+
696
+ | Item | Size |
697
+ |------------------------------------------------|--------------|
698
+ | Hub source, `lb-hub.browser.ts` and `lb-apply.ts` | 929 lines, comments included |
699
+ | Hub alone, esbuild `--minify` | 8,302 bytes |
700
+ | Hub alone, minified then `gzip -9` | 3,377 bytes |
701
+
702
+ Each navigation is one POST. Each interaction is one POST whose response
703
+ carries the refreshed and patched results. On the server, a page's queries
704
+ run one after another in declaration order, then the response is serialized
705
+ as JSON. No HTML is rendered on the server after the build.
706
+
707
+ `app.html` grows with the number and size of pages, and all of it is parsed
708
+ on first load. Lazy loading of page templates is a roadmap item. Theory
709
+ states that performance measurements will be published separately; none are
710
+ published as of this writing.
711
+
712
+ ### Elsewhere
713
+
714
+ | Project | First load | Navigation | Interaction |
715
+ |------------------|-------------------------------------------------------------------|------------------------------------------------|------------------------------------------|
716
+ | Client-only SPA | HTML shell, framework and application bundles, often split by route; data requests | Route chunk if not loaded; data requests | State update and re-render; data requests |
717
+ | Next.js, Nuxt, SvelteKit, React Router 7 | Server-rendered HTML, framework and route bundles, hydration | RSC payload or loader data, route chunks | Server Function or action request; revalidation |
718
+ | Astro | HTML; JavaScript only for hydrated islands | Full page load, or client router if enabled | Island-local |
719
+ | htmx | Server-rendered HTML and the htmx script | Full page, or `hx-boost` body swap | HTML fragment per request |
720
+ | Turbo | Server-rendered HTML and Turbo | Fetch full page HTML, replace body | Form request and page or stream response |
721
+ | Phoenix LiveView | Server-rendered HTML, then a WebSocket connection and mount | `<.link patch>` and `<.link navigate>` over the socket | Event over the socket, diff back |
722
+ | Blazor Server | Server-rendered HTML, then a SignalR circuit | Over the circuit | Event over the circuit, diff back |
723
+
724
+ ## Surface area
725
+
726
+ TECHREF-1.0 lists every name Loadbare/app owns in one cross-reference.
727
+
728
+ | Owned | Count | Names |
729
+ |-------------------------------------|-------|---------------------------------------------------------------|
730
+ | `lb-*` HTML attributes | 16 | `lb-list`, `lb-row`, `lb-cell`, `lb-show`, `lb-key`, `lb-key-value`, `lb-value`, `lb-action`, `lb-nav-link`, `lb-pending`, `lb-error`, `lb-row-count`, `lb-unknown-page`, `lb-slot`, `lb-template`, `lb-page` |
731
+ | Reserved `lb-action` values | 4 | `lb-row-insert`, `lb-row-update`, `lb-row-delete`, `lb-cell-change` |
732
+ | `lb*` methods on custom elements | 2 | `lbPlaceRow`, `lbRowsLanded` |
733
+ | Attribute namespace for expansion | 1 | `exp-*` |
734
+ | Reserved tags | 1 | `<lb-hub>` |
735
+ | Reserved events | 1 | `lb-request` |
736
+ | Synthetic rows | 1 | `lb-navigation` |
737
+ | Builder flags | 4 | `--src`, `--out`, `--watch`, `--minify` |
738
+ | Reserved file names and patterns | 8 | `chrome.html`, `imports.ts`, `*.page.html`, `*.queries.ts`, `*.requests.ts`, `<tag>.html`, `<tag>.browser.ts`, `*.css` |
739
+ | Reserved `package.json` keys | 1 | `loadbare.widgets` |
740
+ | Server functions | 5 | `row`, `list`, `patch`, `createHub`, `hubRoutes` |
741
+ | Browser exports | 2 | `applyRow`, `applyData` |
742
+
743
+ The technical reference, TECHREF-1.0.md, is 1,181 lines. Theory states that
744
+ the vocabulary fits in an LLM's context window, and discloses that Anthropic
745
+ models writing `.requests.ts` files consistently refresh a list query after
746
+ returning a patch for it.
747
+
748
+ The other projects in the comparison set do not publish a comparable
749
+ cross-reference, so no count is given for them here.
750
+
751
+ ## Accessibility
752
+
753
+ ### Loadbare/app
754
+
755
+ - Pages and widgets are native HTML in light DOM. Loadbare/app adds no ARIA
756
+ roles and no elements of its own except `<lb-hub>`, `<template>` for
757
+ absent branches, and the custom elements an application writes.
758
+ - `aria-busy="true"` is set and removed with `lb-pending`.
759
+ - An element hidden by `lb-show` is inside template content, so it is not in
760
+ the accessibility tree.
761
+ - A widget's markup is in the document and in view-source.
762
+ - On navigation the hub does not move focus, announce the new page, or change
763
+ `document.title`.
764
+ - Checkboxes and radio buttons are not implemented for landing or gathering.
765
+
766
+ ### Elsewhere
767
+
768
+ Next.js includes a route announcer for client navigation. SvelteKit moves
769
+ focus and announces the page title after client navigation. Nuxt provides a
770
+ `<NuxtRouteAnnouncer>` component for an application to place. React Router
771
+ and Vue Router leave announcements and focus to the application. In shadow
772
+ DOM components (Lit by default), `id` references such as `aria-labelledby`
773
+ and `<label for>` do not cross the shadow root boundary.
774
+
775
+ ## Testing
776
+
777
+ ### Loadbare/app
778
+
779
+ [Testing](./testing.md) documents how the package tests itself, in four
780
+ tiers, with `node:test` run through `tsx`, and jsdom for the browser tiers.
781
+ No browser test runner is installed.
782
+
783
+ For an application:
784
+
785
+ - A query is `{ kind, run }`, and `run(ctx)` is a function returning rows.
786
+ - A request entry is `{ run, refresh }`, and `run(ctx, where)` is a function.
787
+ - `createHub(pages)` returns an object whose methods take a page name and a
788
+ `ctx`, with no HTTP server.
789
+ - `applyData(root, data)` lands a response on any DOM root.
790
+ - A widget is a custom element.
791
+
792
+ The documentation describes no testing approach for applications.
793
+
794
+ ### Elsewhere
795
+
796
+ | Project | Common testing tools |
797
+ |------------------|---------------------------------------------------------------|
798
+ | React | Vitest or Jest with React Testing Library; Playwright |
799
+ | Vue | Vitest with Vue Test Utils; Playwright |
800
+ | Angular | TestBed with Jasmine and Karma, or Jest or Vitest; Playwright |
801
+ | Svelte | Vitest with Svelte Testing Library; Playwright |
802
+ | htmx, Turbo | Server-side tests of HTML responses; Playwright or Capybara |
803
+ | Phoenix LiveView | `Phoenix.LiveViewTest`, which drives a LiveView without a browser |
804
+
805
+ ## Release history and churn
806
+
807
+ ### Loadbare/app
808
+
809
+ Loadbare/app is at 0.7.3 and has not reached 1.0. TECHREF-1.0 lists items
810
+ to decide before 1.0 because they would be breaking changes afterward.
811
+ Theory states that 1.0 is scoped so that foreseeable features can be added
812
+ without breaking existing code.
813
+
814
+ Application code consists of:
815
+
816
+ | Part | Standard or dependency |
817
+ |------------------|---------------------------------------------------------|
818
+ | Pages and chrome | HTML |
819
+ | Styles | CSS |
820
+ | Widgets | Custom Elements v1 and `<template>` |
821
+ | Server | Express 5 |
822
+ | Page server code | TypeScript functions taking `ctx` |
823
+ | Binding | The `lb-*` vocabulary |
824
+
825
+ The W3C HTML Templates specification was published as a Working Draft in
826
+ 2013 (see [Prior art](./prior-art.md)). Autonomous custom elements have been
827
+ supported in every major browser since Chromium-based Edge shipped in January
828
+ 2020. Express 5.0 was released in September 2024.
829
+
830
+ Loadbare's history begins with Andromeda in 2003, whose data layer is now
831
+ `@loadbare/db`.
832
+
833
+ ### Elsewhere
834
+
835
+ | Date | Change |
836
+ |----------------|---------------------------------------------------------------------------|
837
+ | September 2016 | Angular 2 released, incompatible with AngularJS |
838
+ | February 2019 | React 16.8 introduces Hooks |
839
+ | September 2020 | Vue 3 released, with the Composition API |
840
+ | December 2021 | AngularJS support ends |
841
+ | October 2022 | Next.js 13 introduces the App Router alongside the Pages Router |
842
+ | December 2023 | Vue 2 reaches end of life |
843
+ | June 2024 | htmx 2.0 released |
844
+ | October 2024 | Svelte 5 released, replacing reactive declarations with runes |
845
+ | November 2024 | React Router 7 released, absorbing Remix |
846
+ | December 2024 | React 19 released, with Actions and stable Server Components |
847
+ | February 2025 | Create React App deprecated |
848
+
849
+ ## What Loadbare/app does not do
850
+
851
+ Each item is current behavior or an open item named in TECHREF-1.0 or the
852
+ Roadmap.
853
+
854
+ | Area | Status in 0.7.3 |
855
+ |-------------------------------|------------------------------------------------------------------------|
856
+ | Nested data | A row never holds rows. Trees are sent as joined rows. |
857
+ | Record URLs and view parameters | A URL names a page. Parameters are an open blocker. |
858
+ | Query arguments from the browser | None. Selection is server state reached through `ctx`. |
859
+ | Checkboxes, radio buttons, file inputs | Not landed and not gathered. Open blocker. |
860
+ | Data types and formatting | No conversion. Queries format values. Open blocker. |
861
+ | Error messages | `lb-error` carries no message. |
862
+ | Field validation | No mechanism. Per-keystroke validation is a roadmap question. |
863
+ | Optimistic updates | None. |
864
+ | Concurrent writers | No version or conflict check. Roadmap question. |
865
+ | Server push | None. No WebSocket or server-sent events. |
866
+ | Offline or local-first use | None. |
867
+ | Server-side rendering for crawlers | None. Every path answers the same document with status 200. |
868
+ | Subpath hosting | Not possible. The hub uses absolute paths. |
869
+ | Chrome-level queries | A widget in the chrome bound to a query requires every page to declare it. Open blocker. |
870
+ | A widget receiving a whole row | Not available. Open blocker. |
871
+ | A nested list per outer row | A nested list receives the same rows in every outer row. A new outer row's nested list stays empty until its name lands again. Open blocker. |
872
+ | Configurable request deadline | Fixed at ten seconds. |
873
+ | Internationalization | Roadmap item. |
874
+ | Lazy page loading | Roadmap item. |
875
+ | Per-widget CSS shipping | Roadmap item. |
876
+ | Dev server, HMR | None. |
877
+ | Language server | Roadmap item. |
878
+ | Browser tests of the framework | Not written. |
879
+
880
+ ## Requirements and fit
881
+
882
+ Theory states the fit condition: data that is, or maps readily to, rows and
883
+ sets of rows, and affordances that map to the standard operations on that
884
+ data.
885
+
886
+ Theory also states the strongest objection it knows of and does not dispute
887
+ it: that the binding attributes and the required relational shape exchange
888
+ one set of accidental complexity for another, and that the system is
889
+ "differently accidental" from other frameworks.
890
+
891
+ The table lists requirements an application may have, and whether each
892
+ approach provides them without additional libraries.
893
+
894
+ | Requirement | Loadbare/app 0.7.3 | Client component + API | Meta-frameworks | Hypermedia | Server-driven stateful |
895
+ |-----------------------------------------------|--------------------|------------------------|-----------------|------------|------------------------|
896
+ | Pages bound to relational query results | yes | application code | application code | server templates | server templates |
897
+ | No endpoint or route design | yes | no | partly (actions, loaders) | no | partly (routes, events) |
898
+ | No client-side state management | yes | no | no | yes | yes |
899
+ | No build step | no | no | no | yes | depends on stack |
900
+ | Server-rendered HTML for crawlers | no | no | yes | yes | yes |
901
+ | Route parameters and record URLs | no | yes | yes | yes | yes |
902
+ | Optimistic updates | no | yes | yes | no | partly |
903
+ | Offline or local-first operation | no | with libraries | with libraries | no | no |
904
+ | Real-time server push | no | with libraries | with libraries | extensions, Turbo Streams over WebSocket | yes |
905
+ | Nested JSON documents | no | yes | yes | server templates | yes |
906
+ | Client-only interaction (canvas, editors, drag and drop) | through custom elements | yes | yes | through scripts | through hooks |
907
+ | Works while the server is unreachable | no | with caching | with caching | no | no |
908
+ | No persistent connection | yes | yes | yes | yes | no |
909
+ | Server code in any language | no (Node, TypeScript) | yes | no (JavaScript, TypeScript) | yes | no (Elixir, PHP, .NET) |
910
+ | Stable 1.0 or later | no | yes | yes | yes | yes |
911
+
912
+ ## Appendix A: Matrix
913
+
914
+ | Aspect | Loadbare/app | Client component | Meta-frameworks | Hypermedia | Server-driven stateful |
915
+ |----------------------|--------------------------------------|---------------------------------|-----------------------------------------|---------------------------------|---------------------------------|
916
+ | State | Server; DOM | Browser memory | Server and browser memory | Server; DOM | Server memory per client |
917
+ | Rendering | Build time | Browser | Server, then browser | Server per request | Server per event |
918
+ | Markup language | HTML with `lb-*` names | JSX or template syntax with expressions | JSX or template syntax | Server templates with `hx-*` or `data-turbo-*` | HEEx, Blade, Razor |
919
+ | Components | Light-DOM custom elements | Framework components | Framework components | Server partials; Stimulus controllers | Server components |
920
+ | Wire | One POST endpoint, JSON rows | Application-designed | Framework-defined | HTML over HTTP | WebSocket or snapshot POSTs |
921
+ | Mutation refresh | Declared `refresh` names; patches | Application code or query invalidation | Revalidation | Response HTML | Re-render |
922
+ | Routing | Flat page names; no parameters | Router library | File-based, with parameters and layouts | Server routes | Server routes |
923
+ | Build | `loadbare-app-build`, esbuild | Vite and others | Vite, Turbopack, others | None required | Stack-specific |
924
+ | CSS | Concatenated, unscoped | Varies | Varies, scoped options | None | None built in |
925
+ | Server | Application's Express app | Application's API | Framework server or adapters | Any | Phoenix, Laravel, ASP.NET Core |
926
+
927
+ ## Appendix B: One page, three ways
928
+
929
+ An invoice page shows the invoice's number and customer, a table of its
930
+ lines, and a Remove button per line. Removing a line changes the invoice
931
+ row, so the invoice is refreshed.
932
+
933
+ ### Loadbare/app
934
+
935
+ ```html
936
+ <!-- invoice.page.html -->
937
+ <section lb-row="invoice">
938
+ <h2 lb-cell="number"></h2>
939
+ <span lb-cell="customer"></span>
940
+ </section>
941
+
942
+ <table>
943
+ <tbody lb-list="invoiceLines">
944
+ <template lb-key="id">
945
+ <tr>
946
+ <td lb-cell="item"></td>
947
+ <td lb-cell="amount"></td>
948
+ <td><button lb-action="lb-row-delete">Remove</button></td>
949
+ </tr>
950
+ </template>
951
+ </tbody>
952
+ </table>
953
+ ```
954
+
955
+ ```ts
956
+ // invoice.queries.ts
957
+ import { list, row, type Queries } from "@loadbare/app/server";
958
+
959
+ export const queries: Queries = {
960
+ invoice: row((ctx) => ctx.db.currentInvoice()),
961
+ invoiceLines: list((ctx) => ctx.db.currentInvoiceLines()),
962
+ };
963
+ ```
964
+
965
+ ```ts
966
+ // invoice.requests.ts
967
+ import { patch, type Requests } from "@loadbare/app/server";
968
+
969
+ export const requests: Requests = {
970
+ crud: {
971
+ invoiceLines: {
972
+ rowDelete: {
973
+ run: async (ctx, where) => {
974
+ await ctx.db.deleteLine(where.key);
975
+ return { invoiceLines: patch({ drop: [where.key] }) };
976
+ },
977
+ refresh: ["invoice"],
978
+ },
979
+ },
980
+ },
981
+ };
982
+ ```
983
+
984
+ Which invoice is current is held on the server and reached through `ctx`.
985
+ The button carries `lb-pending` and `aria-busy` while the request is in
986
+ flight. The request sent is
987
+ `{ "action": "lb-row-delete", "list": "invoiceLines", "key": "42" }`. The
988
+ response carries the refreshed `invoice` row and a patch dropping key `42`.
989
+
990
+ ### Next.js (App Router)
991
+
992
+ ```tsx
993
+ // app/invoices/[id]/page.tsx
994
+ import { db } from "@/lib/db";
995
+ import { deleteLine } from "./actions";
996
+
997
+ export default async function InvoicePage({
998
+ params,
999
+ }: {
1000
+ params: Promise<{ id: string }>;
1001
+ }) {
1002
+ const { id } = await params;
1003
+ const invoice = await db.invoice(id);
1004
+ const lines = await db.invoiceLines(id);
1005
+ return (
1006
+ <>
1007
+ <section>
1008
+ <h2>{invoice.number}</h2>
1009
+ <span>{invoice.customer}</span>
1010
+ </section>
1011
+ <table>
1012
+ <tbody>
1013
+ {lines.map((line) => (
1014
+ <tr key={line.id}>
1015
+ <td>{line.item}</td>
1016
+ <td>{line.amount}</td>
1017
+ <td>
1018
+ <form action={deleteLine.bind(null, id, line.id)}>
1019
+ <button>Remove</button>
1020
+ </form>
1021
+ </td>
1022
+ </tr>
1023
+ ))}
1024
+ </tbody>
1025
+ </table>
1026
+ </>
1027
+ );
1028
+ }
1029
+ ```
1030
+
1031
+ ```ts
1032
+ // app/invoices/[id]/actions.ts
1033
+ "use server";
1034
+ import { revalidatePath } from "next/cache";
1035
+ import { db } from "@/lib/db";
1036
+
1037
+ export async function deleteLine(invoiceId: string, lineId: string) {
1038
+ await db.deleteLine(lineId);
1039
+ revalidatePath(`/invoices/${invoiceId}`);
1040
+ }
1041
+ ```
1042
+
1043
+ The invoice is addressed by URL. `revalidatePath` causes the page's Server
1044
+ Component to render again, and the response carries the new RSC payload for
1045
+ the page. A pending state on the button requires a Client Component using
1046
+ `useFormStatus`.
1047
+
1048
+ ### htmx with Express
1049
+
1050
+ ```html
1051
+ <!-- rendered by the server for GET /invoices/7 -->
1052
+ <section hx-get="/invoices/7/header" hx-trigger="line-deleted from:body">
1053
+ <h2>INV-0007</h2>
1054
+ <span>Acme</span>
1055
+ </section>
1056
+
1057
+ <table>
1058
+ <tbody hx-target="closest tr" hx-swap="outerHTML">
1059
+ <tr>
1060
+ <td>Widget</td>
1061
+ <td>12.00</td>
1062
+ <td><button hx-delete="/invoices/7/lines/42">Remove</button></td>
1063
+ </tr>
1064
+ </tbody>
1065
+ </table>
1066
+ ```
1067
+
1068
+ ```ts
1069
+ // server.ts, alongside the route and template that render the full page
1070
+ app.delete("/invoices/:id/lines/:line", async (req, res) => {
1071
+ await db.deleteLine(req.params.line);
1072
+ res.set("HX-Trigger", "line-deleted").send("");
1073
+ });
1074
+
1075
+ app.get("/invoices/:id/header", async (req, res) => {
1076
+ const invoice = await db.invoice(req.params.id);
1077
+ res.send(
1078
+ `<h2>${escapeHtml(invoice.number)}</h2><span>${escapeHtml(invoice.customer)}</span>`,
1079
+ );
1080
+ });
1081
+ ```
1082
+
1083
+ The empty response replaces the row, which removes it. The `HX-Trigger`
1084
+ header fires `line-deleted`, and the section requests its own fragment. The
1085
+ application designs three routes and renders three HTML responses, and
1086
+ escapes values in them. The button carries the `htmx-request` class while
1087
+ the request is in flight.
1088
+
1089
+ ## Appendix C: Glossary of nearest equivalents
1090
+
1091
+ Each equivalent is approximate.
1092
+
1093
+ | Loadbare/app | Nearest equivalents elsewhere |
1094
+ |-------------------------------|----------------------------------------------------------------------------------|
1095
+ | Hub | Client runtime; router plus data layer |
1096
+ | Chrome (`chrome.html`) | Root layout (`app/layout.tsx`, `+layout.svelte`); application shell |
1097
+ | Page (`<name>.page.html`) | Route component; page template |
1098
+ | Query (`row()`, `list()`) | Loader (React Router 7); load function (SvelteKit); data read in a Server Component |
1099
+ | `onPageEnter` | LiveView `mount`; code at the top of a loader |
1100
+ | Declared action | Route action (React Router 7); form action (SvelteKit); Server Function; LiveView `handle_event` |
1101
+ | `crud` operation | Resource controller action (Rails); REST verb on a row |
1102
+ | `refresh` | Revalidation; `invalidateQueries`; `revalidatePath` |
1103
+ | `patch({ rows, drop })` | Keyed partial update; Turbo Stream `append`/`replace`/`remove`; LiveView `stream_insert`/`stream_delete` |
1104
+ | Landing | Render and commit; swap; patch |
1105
+ | `lb-list`, `lb-row`, `lb-cell` | IE4 `DATASRC` and `DATAFLD`; `v-for` and `{{ }}`; `x-for` and `x-text` |
1106
+ | `lb-key` | `key` (React, Vue); `track` (Angular); keyed `each` (Svelte) |
1107
+ | `lb-show` | `v-if`, `@if`, `{#if}`, `x-if` |
1108
+ | `lb-value` | A prop; an observed attribute |
1109
+ | `lb-action` | `hx-post`; `phx-click`; `wire:click`; `<form action>` |
1110
+ | `lb-request` event | `htmx:beforeRequest`; a component `emit` |
1111
+ | `lb-pending` | `htmx-request` class; `phx-click-loading`; `useFormStatus().pending`; `wire:loading` |
1112
+ | `lb-error` | `htmx:responseError`; an error boundary |
1113
+ | `lb-row-count` | An empty-state conditional in a template |
1114
+ | `lb-nav-link` | `<Link>`; `hx-boost`; Turbo Drive |
1115
+ | `lb-navigation` | `useLocation`; `page.url` (SvelteKit); `$route` (Vue Router) |
1116
+ | Widget | Component; custom element |
1117
+ | Expansion | Server-side include; partial; build-time component render (Astro) |
1118
+ | `exp-` parameter | A prop fixed at build time |
1119
+ | `lb-slot`, `lb-template` | Default slot and named slots; `ng-content` with `select` |
1120
+ | `lbPlaceRow`, `lbRowsLanded` | Custom list rendering; a render prop |
1121
+ | `ctx` / `HubContext` | `event.locals` (SvelteKit); loader `context` (React Router 7); request-scoped dependency injection |
1122
+ | `imports.ts` | Installing and registering a component library |
1123
+ | `pages.ts` | Generated route manifest |