jskelet 0.6.3 → 0.6.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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/docs/en/05-islands.md
CHANGED
|
@@ -1,497 +1,497 @@
|
|
|
1
|
-
# 05 — Islands
|
|
2
|
-
|
|
3
|
-
This document explains how interactivity is added: the `data-island` contract,
|
|
4
|
-
passing props, the three hydration strategies and the IntersectionObserver
|
|
5
|
-
logic, the structure of `client/entries/*` and loading extra entries per page,
|
|
6
|
-
the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
|
|
7
|
-
`start`), `createStore` for sharing state between islands, the DOM helpers,
|
|
8
|
-
`startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
|
|
9
|
-
looks like this is in [02-architecture.md](./02-architecture.md), and how the
|
|
10
|
-
bundle is produced is in [08-build.md](./08-build.md).
|
|
11
|
-
|
|
12
|
-
## The contract
|
|
13
|
-
|
|
14
|
-
The server HTML is complete; an island only adds behaviour. There are three
|
|
15
|
-
pieces.
|
|
16
|
-
|
|
17
|
-
**1. A marker in the template.**
|
|
18
|
-
|
|
19
|
-
```ejs
|
|
20
|
-
<div data-island="counter" data-island-props='{"start":5}'></div>
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
**2. The island module — a named export called `mount`.**
|
|
24
|
-
|
|
25
|
-
```js
|
|
26
|
-
// client/islands/counter.js
|
|
27
|
-
/**
|
|
28
|
-
* @param {HTMLElement} element
|
|
29
|
-
* @param {{ start?: number }} props
|
|
30
|
-
* @returns {void | (() => void)} cleanup function (optional)
|
|
31
|
-
*/
|
|
32
|
-
export function mount(element, props) {
|
|
33
|
-
let value = props.start ?? 0;
|
|
34
|
-
// …
|
|
35
|
-
}
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
**3. Registration in an entry.**
|
|
39
|
-
|
|
40
|
-
```js
|
|
41
|
-
// client/entries/main.js
|
|
42
|
-
import { registerAll, start } from "jskelet/client";
|
|
43
|
-
|
|
44
|
-
registerAll({
|
|
45
|
-
counter: () => import("../islands/counter.js"),
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
start();
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
The loader being a dynamic import is the core of the model: the module is
|
|
52
|
-
downloaded only if that island actually exists on the page **and** its mount
|
|
53
|
-
condition is met. Growing this map does not grow the initial load.
|
|
54
|
-
|
|
55
|
-
## HTML attributes
|
|
56
|
-
|
|
57
|
-
| Attribute | Meaning |
|
|
58
|
-
| --- | --- |
|
|
59
|
-
| `data-island="name"` | The registered name of the island to mount. Required. |
|
|
60
|
-
| `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
|
|
61
|
-
| `data-island-eager` | Mount immediately, independent of visibility. |
|
|
62
|
-
| `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
|
|
63
|
-
| `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
|
|
64
|
-
|
|
65
|
-
Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
|
|
66
|
-
the easiest way. If you produce the values on the server, using `jsonScript()`
|
|
67
|
-
or `attrs()` avoids escaping mistakes:
|
|
68
|
-
|
|
69
|
-
```ejs
|
|
70
|
-
<div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## Hydration strategies
|
|
74
|
-
|
|
75
|
-
### The default: tied to visibility
|
|
76
|
-
|
|
77
|
-
Every island is handed to an `IntersectionObserver`
|
|
78
|
-
(`rootMargin: "200px 0px"`). Ones already on screen fire on the first
|
|
79
|
-
observation anyway; ones off screen are never downloaded until they are
|
|
80
|
-
scrolled to. Once an element becomes visible it is unobserved.
|
|
81
|
-
|
|
82
|
-
The mounting work is also deferred to idle time (`requestIdleCallback`,
|
|
83
|
-
`timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
|
|
84
|
-
that become visible at the same time turn into a single long task, TBT and INP
|
|
85
|
-
suffer.
|
|
86
|
-
|
|
87
|
-
### `data-island-eager`
|
|
88
|
-
|
|
89
|
-
Visibility is not awaited, it mounts directly. For islands that apply to the
|
|
90
|
-
whole page, such as header behaviour, a cookie banner or a theme switcher.
|
|
91
|
-
|
|
92
|
-
```ejs
|
|
93
|
-
<header data-island="header" data-island-eager></header>
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### `data-island-idle`
|
|
97
|
-
|
|
98
|
-
Held back until the `load` event completes and the main thread frees up, even
|
|
99
|
-
if it is visible. For heavy but non-critical modules that appear in the first
|
|
100
|
-
viewport — for example a mini chart that pulls in a charting library — so that
|
|
101
|
-
they do not compete with LCP.
|
|
102
|
-
|
|
103
|
-
```ejs
|
|
104
|
-
<div data-island="sparkline" data-island-idle></div>
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
If `document.readyState` is already `complete` when the page loads, the wait is
|
|
108
|
-
skipped and it is deferred straight to idle time.
|
|
109
|
-
|
|
110
|
-
### Hidden elements
|
|
111
|
-
|
|
112
|
-
A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
|
|
113
|
-
**never** reports it. That is why `hydrate()` reads its measurements in one go
|
|
114
|
-
(`getClientRects().length > 0`) and mounts elements without a box directly
|
|
115
|
-
instead of handing them to the observer. Reading the measurements in one go is
|
|
116
|
-
deliberate too: since no write comes in between there is only a single reflow.
|
|
117
|
-
|
|
118
|
-
The practical consequence: you can start a modal as `hidden` and its island
|
|
119
|
-
will still mount.
|
|
120
|
-
|
|
121
|
-
## The `client/` directory
|
|
122
|
-
|
|
123
|
-
```
|
|
124
|
-
client/
|
|
125
|
-
├── entries/
|
|
126
|
-
│ ├── main.js shared bootstrap on every page (or main.ts)
|
|
127
|
-
│ └── chart.js only on the pages that ask for it
|
|
128
|
-
└── islands/
|
|
129
|
-
├── counter.ts .js or .ts
|
|
130
|
-
└── chart.js
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
**Every file** under `client/entries/*.{js,ts,mts}` **is an esbuild entry**.
|
|
134
|
-
`main.js` (or `main.ts`) is loaded by the layout on every page (if it is in the
|
|
135
|
-
manifest). Extra entries are loaded only on the pages that ask for them. Two
|
|
136
|
-
extensions for the same stem (`main.js` + `main.ts`) fail the build.
|
|
137
|
-
|
|
138
|
-
```js
|
|
139
|
-
// controller — the manifest key is always *.js
|
|
140
|
-
return { view: "pages/markets", entries: ["chart.js"] };
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
The layout resolves every name in the `entries` array with `asset(entry)` and
|
|
144
|
-
emits a `<script type="module">`. The name is the manifest key (`chart.js`);
|
|
145
|
-
even when the source is `chart.ts`, the unhashed key stays `.js`.
|
|
146
|
-
|
|
147
|
-
Shared `@/lib` modules imported on the server must stay **`.js`** — the Node
|
|
148
|
-
runtime does not resolve `.ts`; TypeScript is compiled only on the esbuild
|
|
149
|
-
client path.
|
|
150
|
-
|
|
151
|
-
Code splitting (`splitting: true`) is on: modules shared by two entries end up
|
|
152
|
-
in a common chunk and are not downloaded twice.
|
|
153
|
-
|
|
154
|
-
`client/islands/` is not a requirement, only the common layout; island modules
|
|
155
|
-
can live anywhere reachable from an entry. The `@/` alias works both on the
|
|
156
|
-
server and in the bundle, so shared modules under `lib/` can use the same
|
|
157
|
-
import style.
|
|
158
|
-
|
|
159
|
-
## Runtime API — `jskelet/client`
|
|
160
|
-
|
|
161
|
-
### `register(name, loader)`
|
|
162
|
-
|
|
163
|
-
Registers a single island. `loader` must be a function returning
|
|
164
|
-
`Promise<{ mount }>`.
|
|
165
|
-
|
|
166
|
-
```js
|
|
167
|
-
import { register } from "jskelet/client";
|
|
168
|
-
|
|
169
|
-
register("counter", () => import("../islands/counter.js"));
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### `registerAll(entries)`
|
|
173
|
-
|
|
174
|
-
Bulk registration in object form. The preferred form in practice.
|
|
175
|
-
|
|
176
|
-
```js
|
|
177
|
-
registerAll({
|
|
178
|
-
counter: () => import("../islands/counter.js"),
|
|
179
|
-
drawer: () => import("../islands/drawer.js"),
|
|
180
|
-
});
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### `hydrate(root?)`
|
|
184
|
-
|
|
185
|
-
Scans all `[data-island]` elements under `root` (defaults to `document`) and
|
|
186
|
-
processes them according to their mount strategy. Already mounted elements are
|
|
187
|
-
skipped.
|
|
188
|
-
|
|
189
|
-
You can call it directly when you need to rescan for islands by hand (for
|
|
190
|
-
example if you added DOM with your own code):
|
|
191
|
-
|
|
192
|
-
```js
|
|
193
|
-
container.innerHTML = html;
|
|
194
|
-
hydrate(container);
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
### `observeDocument()`
|
|
198
|
-
|
|
199
|
-
Sets up a `MutationObserver` on `document.body` and also catches islands added
|
|
200
|
-
to the DOM later (infinite scroll, portals, fragment loading). It returns the
|
|
201
|
-
`MutationObserver` instance so it can be `disconnect()`ed if needed.
|
|
202
|
-
|
|
203
|
-
### `start()`
|
|
204
|
-
|
|
205
|
-
The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
|
|
206
|
-
calls `hydrate()` and `observeDocument()`.
|
|
207
|
-
|
|
208
|
-
```js
|
|
209
|
-
registerAll({ /* … */ });
|
|
210
|
-
start();
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
### Mount behaviour and errors
|
|
214
|
-
|
|
215
|
-
- An element is **not mounted twice** with the same island name; the record is
|
|
216
|
-
kept per element in a `WeakMap`.
|
|
217
|
-
- A warning is printed to the console for a name that is not registered:
|
|
218
|
-
`[island] not registered: <name>`.
|
|
219
|
-
- If the module import or `mount()` throws, an error is printed to the console
|
|
220
|
-
(`[island] <name> failed to load`) and **the rest of the page is unaffected**.
|
|
221
|
-
- If `mount()` returns successfully, `data-island-ready="true"` is written on
|
|
222
|
-
the element.
|
|
223
|
-
- `mount()` may return a cleanup function; the framework stores it and runs it
|
|
224
|
-
when `unmount()` is called (see below).
|
|
225
|
-
|
|
226
|
-
### `unmount(root?)`
|
|
227
|
-
|
|
228
|
-
Unmounts the islands under `root`: it runs the stored cleanup functions, removes
|
|
229
|
-
the `data-island-ready` marker and clears the registration, so the same node can
|
|
230
|
-
be hydrated again if it re-enters the DOM. `root` itself may be an island.
|
|
231
|
-
|
|
232
|
-
It is **required** when you replace a region of the DOM:
|
|
233
|
-
|
|
234
|
-
```js
|
|
235
|
-
import { hydrate, unmount } from "jskelet/client";
|
|
236
|
-
|
|
237
|
-
unmount(container);
|
|
238
|
-
container.innerHTML = html;
|
|
239
|
-
hydrate(container);
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
Skipping it produces the leak that is easiest to miss. The islands inside a
|
|
243
|
-
region replaced with `innerHTML` leave the DOM, but the listeners they installed
|
|
244
|
-
on `document`/`window` and their `setInterval` timers keep running; after a few
|
|
245
|
-
swaps the same work runs dozens of times.
|
|
246
|
-
|
|
247
|
-
```js
|
|
248
|
-
export function mount(element) {
|
|
249
|
-
const timer = setInterval(() => tick(element), 1000);
|
|
250
|
-
const onResize = () => layout(element);
|
|
251
|
-
window.addEventListener("resize", onResize);
|
|
252
|
-
|
|
253
|
-
return () => {
|
|
254
|
-
clearInterval(timer);
|
|
255
|
-
window.removeEventListener("resize", onResize);
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
`swap()` and the form helpers call `unmount()` themselves; you call it wherever
|
|
261
|
-
you change the DOM by hand.
|
|
262
|
-
|
|
263
|
-
### `swap(target, url, options?)` and `startSwapLinks(root?)`
|
|
264
|
-
|
|
265
|
-
Replaces a region with a partial from the server: it unmounts the old subtree,
|
|
266
|
-
writes the content, hydrates it again and restores focus if it was lost.
|
|
267
|
-
|
|
268
|
-
```html
|
|
269
|
-
<a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
The server side and the full set of options are in
|
|
273
|
-
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
274
|
-
|
|
275
|
-
### `enhanceForm(form)` and `startForms(root?)`
|
|
276
|
-
|
|
277
|
-
Submits forms carrying `data-enhance` without a page reload, while the normal
|
|
278
|
-
POST + redirect flow keeps working with JavaScript disabled. The whole contract
|
|
279
|
-
is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
280
|
-
|
|
281
|
-
## Sharing state: `createStore`
|
|
282
|
-
|
|
283
|
-
A minimal pub/sub used in place of React Context. It replaces the
|
|
284
|
-
`useSyncExternalStore` bridge: you just `subscribe`.
|
|
285
|
-
|
|
286
|
-
```js
|
|
287
|
-
// client/stores/theme.js
|
|
288
|
-
import { createStore } from "jskelet/client";
|
|
289
|
-
|
|
290
|
-
export const theme = createStore("light");
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
```js
|
|
294
|
-
// client/islands/theme-toggle.js
|
|
295
|
-
import { theme } from "../stores/theme.js";
|
|
296
|
-
|
|
297
|
-
export function mount(element) {
|
|
298
|
-
const paint = (value) => {
|
|
299
|
-
element.textContent = value === "light" ? "Dark theme" : "Light theme";
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
const unsubscribe = theme.subscribe(paint);
|
|
303
|
-
paint(theme.get());
|
|
304
|
-
|
|
305
|
-
element.addEventListener("click", () => {
|
|
306
|
-
theme.set((prev) => (prev === "light" ? "dark" : "light"));
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
return unsubscribe;
|
|
310
|
-
}
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
API:
|
|
314
|
-
|
|
315
|
-
| Member | Behaviour |
|
|
316
|
-
| --- | --- |
|
|
317
|
-
| `get()` | The current value |
|
|
318
|
-
| `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
|
|
319
|
-
| `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
|
|
320
|
-
|
|
321
|
-
## DOM helpers
|
|
322
|
-
|
|
323
|
-
`jskelet/client` provides a small set of helpers that islands share.
|
|
324
|
-
|
|
325
|
-
| Function | Signature | Behaviour |
|
|
326
|
-
| --- | --- | --- |
|
|
327
|
-
| `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
|
|
328
|
-
| `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
|
|
329
|
-
| `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
|
|
330
|
-
| `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
|
|
331
|
-
| `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
|
|
332
|
-
| `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
|
|
333
|
-
| `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
|
|
334
|
-
| `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
|
|
335
|
-
|
|
336
|
-
`on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
|
|
337
|
-
cleanup function:
|
|
338
|
-
|
|
339
|
-
```js
|
|
340
|
-
import { on, onClick, raf } from "jskelet/client";
|
|
341
|
-
|
|
342
|
-
export function mount(element) {
|
|
343
|
-
const offClick = onClick(element, "[data-tab]", (event, target) => {
|
|
344
|
-
selectTab(target.dataset.tab);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
|
|
348
|
-
passive: true,
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
return () => {
|
|
352
|
-
offClick();
|
|
353
|
-
offScroll();
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
`getOverlayRoot()` is for moving modal/drawer content: if the layout has
|
|
359
|
-
`<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
|
|
360
|
-
portal prevents an ancestor element carrying `overflow` or `transform` from
|
|
361
|
-
clipping a `position: fixed` overlay.
|
|
362
|
-
|
|
363
|
-
## `startSafeImages()`
|
|
364
|
-
|
|
365
|
-
A single document listener for images that fail to load. It is **deliberately
|
|
366
|
-
not an island:** an image-heavy page can have 80+ `<img>` elements, and
|
|
367
|
-
attaching a separate island to each one (observer + dynamic import + mount) is
|
|
368
|
-
a serious hydration cost just for the possibility of an error.
|
|
369
|
-
|
|
370
|
-
```js
|
|
371
|
-
// client/entries/main.js
|
|
372
|
-
import { registerAll, start, startSafeImages } from "jskelet/client";
|
|
373
|
-
|
|
374
|
-
registerAll({ /* … */ });
|
|
375
|
-
startSafeImages();
|
|
376
|
-
start();
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
Usage, on the template side:
|
|
380
|
-
|
|
381
|
-
```ejs
|
|
382
|
-
<%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
|
|
383
|
-
<img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
|
|
384
|
-
|
|
385
|
-
<%# 2. Your own error view %>
|
|
386
|
-
<div data-safe-image-host>
|
|
387
|
-
<img src="/kapak.png" alt="Kapak" data-safe-image>
|
|
388
|
-
<template data-safe-image-fallback>
|
|
389
|
-
<div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
|
|
390
|
-
</template>
|
|
391
|
-
</div>
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
How it works:
|
|
395
|
-
|
|
396
|
-
- A single `error` listener is installed on the document **in the capture
|
|
397
|
-
phase**. The `error` event does not bubble but it can be seen in the capture
|
|
398
|
-
phase; that is why a single listener covers all images and ones added to the
|
|
399
|
-
DOM later are covered automatically.
|
|
400
|
-
- If there is a `data-safe-image-host` wrapper **and** a
|
|
401
|
-
`<template data-safe-image-fallback>` inside it, the whole wrapper is
|
|
402
|
-
replaced with the template content. The framework imposes no styling.
|
|
403
|
-
- Otherwise a minimal block is put in place of the image: `role="img"`, the
|
|
404
|
-
`alt` (or `data-fallback-label`) value as `aria-label`, the image's
|
|
405
|
-
`className` plus `data-fallback-class`, and, if `width`/`height` exist, the
|
|
406
|
-
same dimensions as an inline style. Preserving the dimensions prevents layout
|
|
407
|
-
shift (CLS) during the swap.
|
|
408
|
-
- Images that failed before JS ran produce no event; that is why a single scan
|
|
409
|
-
is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
|
|
410
|
-
`complete` with `naturalWidth === 0` are replaced.
|
|
411
|
-
|
|
412
|
-
## The deferred panel (fragment) pattern
|
|
413
|
-
|
|
414
|
-
When you want to remove a heavy, secondary section (comments, related articles,
|
|
415
|
-
a long table) from the initial HTML response entirely, the combination of an
|
|
416
|
-
island plus a layout-less render is used. The framework has no special API for
|
|
417
|
-
this; it is a combination of two pieces you already have:
|
|
418
|
-
|
|
419
|
-
**1. A layout-less fragment endpoint on the server** (`renderView`, see
|
|
420
|
-
[03-routing.md](./03-routing.md)):
|
|
421
|
-
|
|
422
|
-
```js
|
|
423
|
-
// routes/80-fragments.mjs
|
|
424
|
-
export default function register(app, { renderView }) {
|
|
425
|
-
app.get("/_fragment/comments/:id", async (req, res) => {
|
|
426
|
-
const comments = await getComments(req.params.id);
|
|
427
|
-
res.type("html").send(await renderView("fragments/comments", { comments }));
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
**2. A placeholder island on the page.** Because it mounts on visibility,
|
|
433
|
-
neither the module nor the fragment is downloaded if the visitor never scrolls
|
|
434
|
-
to that section:
|
|
435
|
-
|
|
436
|
-
```ejs
|
|
437
|
-
<div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
**3. The island fetches the fragment, inserts it and hydrates the islands
|
|
441
|
-
inside it:**
|
|
442
|
-
|
|
443
|
-
```js
|
|
444
|
-
// client/islands/deferred.js
|
|
445
|
-
import { hydrate } from "jskelet/client";
|
|
446
|
-
|
|
447
|
-
export async function mount(element, { src }) {
|
|
448
|
-
try {
|
|
449
|
-
const response = await fetch(src, { headers: { accept: "text/html" } });
|
|
450
|
-
if (!response.ok) return;
|
|
451
|
-
|
|
452
|
-
element.innerHTML = await response.text();
|
|
453
|
-
hydrate(element);
|
|
454
|
-
} catch {
|
|
455
|
-
// Secondary content: give up silently, don't affect the rest of the page.
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
If `observeDocument()` is already running the `hydrate()` call on the last line
|
|
461
|
-
is unnecessary; still, calling it explicitly makes it behave correctly in a
|
|
462
|
-
setup that does not use `start()` either.
|
|
463
|
-
|
|
464
|
-
The `/_fragment/` prefix is recommended for fragment paths: because it is in
|
|
465
|
-
the default `prewarmSkip` list, the prewarm round does not scan those endpoints
|
|
466
|
-
([06-caching.md](./06-caching.md)).
|
|
467
|
-
|
|
468
|
-
## Environment variables and `clientEnv`
|
|
469
|
-
|
|
470
|
-
There is no `process` in the browser, but modules shared with the server may
|
|
471
|
-
still read `process.env`. The keys declared through `jskelet.config.mjs` →
|
|
472
|
-
`clientEnv` are inlined into the bundle at build time:
|
|
473
|
-
|
|
474
|
-
```js
|
|
475
|
-
export default {
|
|
476
|
-
clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
|
|
477
|
-
};
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
|
|
481
|
-
clear from the config rather than from the name. All of `process.env` is
|
|
482
|
-
defined as a single object, so reading a key that is not in the list returns
|
|
483
|
-
`undefined` instead of crashing. `NODE_ENV` is always inlined.
|
|
484
|
-
|
|
485
|
-
## Browser support
|
|
486
|
-
|
|
487
|
-
The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
|
|
488
|
-
`safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
|
|
489
|
-
lower bound of the island model; transpiling to anything older grows the output
|
|
490
|
-
and gains no visitors. Even if JS never runs, the page stays readable because
|
|
491
|
-
the server HTML is complete.
|
|
492
|
-
|
|
493
|
-
## What's next
|
|
494
|
-
|
|
495
|
-
- The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
|
|
496
|
-
- The controller side of the `entries` field: [03-routing.md](./03-routing.md)
|
|
497
|
-
- Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
|
|
1
|
+
# 05 — Islands
|
|
2
|
+
|
|
3
|
+
This document explains how interactivity is added: the `data-island` contract,
|
|
4
|
+
passing props, the three hydration strategies and the IntersectionObserver
|
|
5
|
+
logic, the structure of `client/entries/*` and loading extra entries per page,
|
|
6
|
+
the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
|
|
7
|
+
`start`), `createStore` for sharing state between islands, the DOM helpers,
|
|
8
|
+
`startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
|
|
9
|
+
looks like this is in [02-architecture.md](./02-architecture.md), and how the
|
|
10
|
+
bundle is produced is in [08-build.md](./08-build.md).
|
|
11
|
+
|
|
12
|
+
## The contract
|
|
13
|
+
|
|
14
|
+
The server HTML is complete; an island only adds behaviour. There are three
|
|
15
|
+
pieces.
|
|
16
|
+
|
|
17
|
+
**1. A marker in the template.**
|
|
18
|
+
|
|
19
|
+
```ejs
|
|
20
|
+
<div data-island="counter" data-island-props='{"start":5}'></div>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**2. The island module — a named export called `mount`.**
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// client/islands/counter.js
|
|
27
|
+
/**
|
|
28
|
+
* @param {HTMLElement} element
|
|
29
|
+
* @param {{ start?: number }} props
|
|
30
|
+
* @returns {void | (() => void)} cleanup function (optional)
|
|
31
|
+
*/
|
|
32
|
+
export function mount(element, props) {
|
|
33
|
+
let value = props.start ?? 0;
|
|
34
|
+
// …
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**3. Registration in an entry.**
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// client/entries/main.js
|
|
42
|
+
import { registerAll, start } from "jskelet/client";
|
|
43
|
+
|
|
44
|
+
registerAll({
|
|
45
|
+
counter: () => import("../islands/counter.js"),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
start();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The loader being a dynamic import is the core of the model: the module is
|
|
52
|
+
downloaded only if that island actually exists on the page **and** its mount
|
|
53
|
+
condition is met. Growing this map does not grow the initial load.
|
|
54
|
+
|
|
55
|
+
## HTML attributes
|
|
56
|
+
|
|
57
|
+
| Attribute | Meaning |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `data-island="name"` | The registered name of the island to mount. Required. |
|
|
60
|
+
| `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
|
|
61
|
+
| `data-island-eager` | Mount immediately, independent of visibility. |
|
|
62
|
+
| `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
|
|
63
|
+
| `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
|
|
64
|
+
|
|
65
|
+
Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
|
|
66
|
+
the easiest way. If you produce the values on the server, using `jsonScript()`
|
|
67
|
+
or `attrs()` avoids escaping mistakes:
|
|
68
|
+
|
|
69
|
+
```ejs
|
|
70
|
+
<div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Hydration strategies
|
|
74
|
+
|
|
75
|
+
### The default: tied to visibility
|
|
76
|
+
|
|
77
|
+
Every island is handed to an `IntersectionObserver`
|
|
78
|
+
(`rootMargin: "200px 0px"`). Ones already on screen fire on the first
|
|
79
|
+
observation anyway; ones off screen are never downloaded until they are
|
|
80
|
+
scrolled to. Once an element becomes visible it is unobserved.
|
|
81
|
+
|
|
82
|
+
The mounting work is also deferred to idle time (`requestIdleCallback`,
|
|
83
|
+
`timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
|
|
84
|
+
that become visible at the same time turn into a single long task, TBT and INP
|
|
85
|
+
suffer.
|
|
86
|
+
|
|
87
|
+
### `data-island-eager`
|
|
88
|
+
|
|
89
|
+
Visibility is not awaited, it mounts directly. For islands that apply to the
|
|
90
|
+
whole page, such as header behaviour, a cookie banner or a theme switcher.
|
|
91
|
+
|
|
92
|
+
```ejs
|
|
93
|
+
<header data-island="header" data-island-eager></header>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `data-island-idle`
|
|
97
|
+
|
|
98
|
+
Held back until the `load` event completes and the main thread frees up, even
|
|
99
|
+
if it is visible. For heavy but non-critical modules that appear in the first
|
|
100
|
+
viewport — for example a mini chart that pulls in a charting library — so that
|
|
101
|
+
they do not compete with LCP.
|
|
102
|
+
|
|
103
|
+
```ejs
|
|
104
|
+
<div data-island="sparkline" data-island-idle></div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If `document.readyState` is already `complete` when the page loads, the wait is
|
|
108
|
+
skipped and it is deferred straight to idle time.
|
|
109
|
+
|
|
110
|
+
### Hidden elements
|
|
111
|
+
|
|
112
|
+
A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
|
|
113
|
+
**never** reports it. That is why `hydrate()` reads its measurements in one go
|
|
114
|
+
(`getClientRects().length > 0`) and mounts elements without a box directly
|
|
115
|
+
instead of handing them to the observer. Reading the measurements in one go is
|
|
116
|
+
deliberate too: since no write comes in between there is only a single reflow.
|
|
117
|
+
|
|
118
|
+
The practical consequence: you can start a modal as `hidden` and its island
|
|
119
|
+
will still mount.
|
|
120
|
+
|
|
121
|
+
## The `client/` directory
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
client/
|
|
125
|
+
├── entries/
|
|
126
|
+
│ ├── main.js shared bootstrap on every page (or main.ts)
|
|
127
|
+
│ └── chart.js only on the pages that ask for it
|
|
128
|
+
└── islands/
|
|
129
|
+
├── counter.ts .js or .ts
|
|
130
|
+
└── chart.js
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Every file** under `client/entries/*.{js,ts,mts}` **is an esbuild entry**.
|
|
134
|
+
`main.js` (or `main.ts`) is loaded by the layout on every page (if it is in the
|
|
135
|
+
manifest). Extra entries are loaded only on the pages that ask for them. Two
|
|
136
|
+
extensions for the same stem (`main.js` + `main.ts`) fail the build.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
// controller — the manifest key is always *.js
|
|
140
|
+
return { view: "pages/markets", entries: ["chart.js"] };
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The layout resolves every name in the `entries` array with `asset(entry)` and
|
|
144
|
+
emits a `<script type="module">`. The name is the manifest key (`chart.js`);
|
|
145
|
+
even when the source is `chart.ts`, the unhashed key stays `.js`.
|
|
146
|
+
|
|
147
|
+
Shared `@/lib` modules imported on the server must stay **`.js`** — the Node
|
|
148
|
+
runtime does not resolve `.ts`; TypeScript is compiled only on the esbuild
|
|
149
|
+
client path.
|
|
150
|
+
|
|
151
|
+
Code splitting (`splitting: true`) is on: modules shared by two entries end up
|
|
152
|
+
in a common chunk and are not downloaded twice.
|
|
153
|
+
|
|
154
|
+
`client/islands/` is not a requirement, only the common layout; island modules
|
|
155
|
+
can live anywhere reachable from an entry. The `@/` alias works both on the
|
|
156
|
+
server and in the bundle, so shared modules under `lib/` can use the same
|
|
157
|
+
import style.
|
|
158
|
+
|
|
159
|
+
## Runtime API — `jskelet/client`
|
|
160
|
+
|
|
161
|
+
### `register(name, loader)`
|
|
162
|
+
|
|
163
|
+
Registers a single island. `loader` must be a function returning
|
|
164
|
+
`Promise<{ mount }>`.
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
import { register } from "jskelet/client";
|
|
168
|
+
|
|
169
|
+
register("counter", () => import("../islands/counter.js"));
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `registerAll(entries)`
|
|
173
|
+
|
|
174
|
+
Bulk registration in object form. The preferred form in practice.
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
registerAll({
|
|
178
|
+
counter: () => import("../islands/counter.js"),
|
|
179
|
+
drawer: () => import("../islands/drawer.js"),
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `hydrate(root?)`
|
|
184
|
+
|
|
185
|
+
Scans all `[data-island]` elements under `root` (defaults to `document`) and
|
|
186
|
+
processes them according to their mount strategy. Already mounted elements are
|
|
187
|
+
skipped.
|
|
188
|
+
|
|
189
|
+
You can call it directly when you need to rescan for islands by hand (for
|
|
190
|
+
example if you added DOM with your own code):
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
container.innerHTML = html;
|
|
194
|
+
hydrate(container);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `observeDocument()`
|
|
198
|
+
|
|
199
|
+
Sets up a `MutationObserver` on `document.body` and also catches islands added
|
|
200
|
+
to the DOM later (infinite scroll, portals, fragment loading). It returns the
|
|
201
|
+
`MutationObserver` instance so it can be `disconnect()`ed if needed.
|
|
202
|
+
|
|
203
|
+
### `start()`
|
|
204
|
+
|
|
205
|
+
The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
|
|
206
|
+
calls `hydrate()` and `observeDocument()`.
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
registerAll({ /* … */ });
|
|
210
|
+
start();
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Mount behaviour and errors
|
|
214
|
+
|
|
215
|
+
- An element is **not mounted twice** with the same island name; the record is
|
|
216
|
+
kept per element in a `WeakMap`.
|
|
217
|
+
- A warning is printed to the console for a name that is not registered:
|
|
218
|
+
`[island] not registered: <name>`.
|
|
219
|
+
- If the module import or `mount()` throws, an error is printed to the console
|
|
220
|
+
(`[island] <name> failed to load`) and **the rest of the page is unaffected**.
|
|
221
|
+
- If `mount()` returns successfully, `data-island-ready="true"` is written on
|
|
222
|
+
the element.
|
|
223
|
+
- `mount()` may return a cleanup function; the framework stores it and runs it
|
|
224
|
+
when `unmount()` is called (see below).
|
|
225
|
+
|
|
226
|
+
### `unmount(root?)`
|
|
227
|
+
|
|
228
|
+
Unmounts the islands under `root`: it runs the stored cleanup functions, removes
|
|
229
|
+
the `data-island-ready` marker and clears the registration, so the same node can
|
|
230
|
+
be hydrated again if it re-enters the DOM. `root` itself may be an island.
|
|
231
|
+
|
|
232
|
+
It is **required** when you replace a region of the DOM:
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
import { hydrate, unmount } from "jskelet/client";
|
|
236
|
+
|
|
237
|
+
unmount(container);
|
|
238
|
+
container.innerHTML = html;
|
|
239
|
+
hydrate(container);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Skipping it produces the leak that is easiest to miss. The islands inside a
|
|
243
|
+
region replaced with `innerHTML` leave the DOM, but the listeners they installed
|
|
244
|
+
on `document`/`window` and their `setInterval` timers keep running; after a few
|
|
245
|
+
swaps the same work runs dozens of times.
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
export function mount(element) {
|
|
249
|
+
const timer = setInterval(() => tick(element), 1000);
|
|
250
|
+
const onResize = () => layout(element);
|
|
251
|
+
window.addEventListener("resize", onResize);
|
|
252
|
+
|
|
253
|
+
return () => {
|
|
254
|
+
clearInterval(timer);
|
|
255
|
+
window.removeEventListener("resize", onResize);
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`swap()` and the form helpers call `unmount()` themselves; you call it wherever
|
|
261
|
+
you change the DOM by hand.
|
|
262
|
+
|
|
263
|
+
### `swap(target, url, options?)` and `startSwapLinks(root?)`
|
|
264
|
+
|
|
265
|
+
Replaces a region with a partial from the server: it unmounts the old subtree,
|
|
266
|
+
writes the content, hydrates it again and restores focus if it was lost.
|
|
267
|
+
|
|
268
|
+
```html
|
|
269
|
+
<a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The server side and the full set of options are in
|
|
273
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
274
|
+
|
|
275
|
+
### `enhanceForm(form)` and `startForms(root?)`
|
|
276
|
+
|
|
277
|
+
Submits forms carrying `data-enhance` without a page reload, while the normal
|
|
278
|
+
POST + redirect flow keeps working with JavaScript disabled. The whole contract
|
|
279
|
+
is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
280
|
+
|
|
281
|
+
## Sharing state: `createStore`
|
|
282
|
+
|
|
283
|
+
A minimal pub/sub used in place of React Context. It replaces the
|
|
284
|
+
`useSyncExternalStore` bridge: you just `subscribe`.
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
// client/stores/theme.js
|
|
288
|
+
import { createStore } from "jskelet/client";
|
|
289
|
+
|
|
290
|
+
export const theme = createStore("light");
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
// client/islands/theme-toggle.js
|
|
295
|
+
import { theme } from "../stores/theme.js";
|
|
296
|
+
|
|
297
|
+
export function mount(element) {
|
|
298
|
+
const paint = (value) => {
|
|
299
|
+
element.textContent = value === "light" ? "Dark theme" : "Light theme";
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const unsubscribe = theme.subscribe(paint);
|
|
303
|
+
paint(theme.get());
|
|
304
|
+
|
|
305
|
+
element.addEventListener("click", () => {
|
|
306
|
+
theme.set((prev) => (prev === "light" ? "dark" : "light"));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return unsubscribe;
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
API:
|
|
314
|
+
|
|
315
|
+
| Member | Behaviour |
|
|
316
|
+
| --- | --- |
|
|
317
|
+
| `get()` | The current value |
|
|
318
|
+
| `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
|
|
319
|
+
| `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
|
|
320
|
+
|
|
321
|
+
## DOM helpers
|
|
322
|
+
|
|
323
|
+
`jskelet/client` provides a small set of helpers that islands share.
|
|
324
|
+
|
|
325
|
+
| Function | Signature | Behaviour |
|
|
326
|
+
| --- | --- | --- |
|
|
327
|
+
| `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
|
|
328
|
+
| `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
|
|
329
|
+
| `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
|
|
330
|
+
| `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
|
|
331
|
+
| `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
|
|
332
|
+
| `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
|
|
333
|
+
| `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
|
|
334
|
+
| `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
|
|
335
|
+
|
|
336
|
+
`on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
|
|
337
|
+
cleanup function:
|
|
338
|
+
|
|
339
|
+
```js
|
|
340
|
+
import { on, onClick, raf } from "jskelet/client";
|
|
341
|
+
|
|
342
|
+
export function mount(element) {
|
|
343
|
+
const offClick = onClick(element, "[data-tab]", (event, target) => {
|
|
344
|
+
selectTab(target.dataset.tab);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
|
|
348
|
+
passive: true,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return () => {
|
|
352
|
+
offClick();
|
|
353
|
+
offScroll();
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`getOverlayRoot()` is for moving modal/drawer content: if the layout has
|
|
359
|
+
`<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
|
|
360
|
+
portal prevents an ancestor element carrying `overflow` or `transform` from
|
|
361
|
+
clipping a `position: fixed` overlay.
|
|
362
|
+
|
|
363
|
+
## `startSafeImages()`
|
|
364
|
+
|
|
365
|
+
A single document listener for images that fail to load. It is **deliberately
|
|
366
|
+
not an island:** an image-heavy page can have 80+ `<img>` elements, and
|
|
367
|
+
attaching a separate island to each one (observer + dynamic import + mount) is
|
|
368
|
+
a serious hydration cost just for the possibility of an error.
|
|
369
|
+
|
|
370
|
+
```js
|
|
371
|
+
// client/entries/main.js
|
|
372
|
+
import { registerAll, start, startSafeImages } from "jskelet/client";
|
|
373
|
+
|
|
374
|
+
registerAll({ /* … */ });
|
|
375
|
+
startSafeImages();
|
|
376
|
+
start();
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Usage, on the template side:
|
|
380
|
+
|
|
381
|
+
```ejs
|
|
382
|
+
<%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
|
|
383
|
+
<img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
|
|
384
|
+
|
|
385
|
+
<%# 2. Your own error view %>
|
|
386
|
+
<div data-safe-image-host>
|
|
387
|
+
<img src="/kapak.png" alt="Kapak" data-safe-image>
|
|
388
|
+
<template data-safe-image-fallback>
|
|
389
|
+
<div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
|
|
390
|
+
</template>
|
|
391
|
+
</div>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
How it works:
|
|
395
|
+
|
|
396
|
+
- A single `error` listener is installed on the document **in the capture
|
|
397
|
+
phase**. The `error` event does not bubble but it can be seen in the capture
|
|
398
|
+
phase; that is why a single listener covers all images and ones added to the
|
|
399
|
+
DOM later are covered automatically.
|
|
400
|
+
- If there is a `data-safe-image-host` wrapper **and** a
|
|
401
|
+
`<template data-safe-image-fallback>` inside it, the whole wrapper is
|
|
402
|
+
replaced with the template content. The framework imposes no styling.
|
|
403
|
+
- Otherwise a minimal block is put in place of the image: `role="img"`, the
|
|
404
|
+
`alt` (or `data-fallback-label`) value as `aria-label`, the image's
|
|
405
|
+
`className` plus `data-fallback-class`, and, if `width`/`height` exist, the
|
|
406
|
+
same dimensions as an inline style. Preserving the dimensions prevents layout
|
|
407
|
+
shift (CLS) during the swap.
|
|
408
|
+
- Images that failed before JS ran produce no event; that is why a single scan
|
|
409
|
+
is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
|
|
410
|
+
`complete` with `naturalWidth === 0` are replaced.
|
|
411
|
+
|
|
412
|
+
## The deferred panel (fragment) pattern
|
|
413
|
+
|
|
414
|
+
When you want to remove a heavy, secondary section (comments, related articles,
|
|
415
|
+
a long table) from the initial HTML response entirely, the combination of an
|
|
416
|
+
island plus a layout-less render is used. The framework has no special API for
|
|
417
|
+
this; it is a combination of two pieces you already have:
|
|
418
|
+
|
|
419
|
+
**1. A layout-less fragment endpoint on the server** (`renderView`, see
|
|
420
|
+
[03-routing.md](./03-routing.md)):
|
|
421
|
+
|
|
422
|
+
```js
|
|
423
|
+
// routes/80-fragments.mjs
|
|
424
|
+
export default function register(app, { renderView }) {
|
|
425
|
+
app.get("/_fragment/comments/:id", async (req, res) => {
|
|
426
|
+
const comments = await getComments(req.params.id);
|
|
427
|
+
res.type("html").send(await renderView("fragments/comments", { comments }));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**2. A placeholder island on the page.** Because it mounts on visibility,
|
|
433
|
+
neither the module nor the fragment is downloaded if the visitor never scrolls
|
|
434
|
+
to that section:
|
|
435
|
+
|
|
436
|
+
```ejs
|
|
437
|
+
<div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**3. The island fetches the fragment, inserts it and hydrates the islands
|
|
441
|
+
inside it:**
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
// client/islands/deferred.js
|
|
445
|
+
import { hydrate } from "jskelet/client";
|
|
446
|
+
|
|
447
|
+
export async function mount(element, { src }) {
|
|
448
|
+
try {
|
|
449
|
+
const response = await fetch(src, { headers: { accept: "text/html" } });
|
|
450
|
+
if (!response.ok) return;
|
|
451
|
+
|
|
452
|
+
element.innerHTML = await response.text();
|
|
453
|
+
hydrate(element);
|
|
454
|
+
} catch {
|
|
455
|
+
// Secondary content: give up silently, don't affect the rest of the page.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
If `observeDocument()` is already running the `hydrate()` call on the last line
|
|
461
|
+
is unnecessary; still, calling it explicitly makes it behave correctly in a
|
|
462
|
+
setup that does not use `start()` either.
|
|
463
|
+
|
|
464
|
+
The `/_fragment/` prefix is recommended for fragment paths: because it is in
|
|
465
|
+
the default `prewarmSkip` list, the prewarm round does not scan those endpoints
|
|
466
|
+
([06-caching.md](./06-caching.md)).
|
|
467
|
+
|
|
468
|
+
## Environment variables and `clientEnv`
|
|
469
|
+
|
|
470
|
+
There is no `process` in the browser, but modules shared with the server may
|
|
471
|
+
still read `process.env`. The keys declared through `jskelet.config.mjs` →
|
|
472
|
+
`clientEnv` are inlined into the bundle at build time:
|
|
473
|
+
|
|
474
|
+
```js
|
|
475
|
+
export default {
|
|
476
|
+
clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
|
|
477
|
+
};
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
|
|
481
|
+
clear from the config rather than from the name. All of `process.env` is
|
|
482
|
+
defined as a single object, so reading a key that is not in the list returns
|
|
483
|
+
`undefined` instead of crashing. `NODE_ENV` is always inlined.
|
|
484
|
+
|
|
485
|
+
## Browser support
|
|
486
|
+
|
|
487
|
+
The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
|
|
488
|
+
`safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
|
|
489
|
+
lower bound of the island model; transpiling to anything older grows the output
|
|
490
|
+
and gains no visitors. Even if JS never runs, the page stays readable because
|
|
491
|
+
the server HTML is complete.
|
|
492
|
+
|
|
493
|
+
## What's next
|
|
494
|
+
|
|
495
|
+
- The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
|
|
496
|
+
- The controller side of the `entries` field: [03-routing.md](./03-routing.md)
|
|
497
|
+
- Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
|