@johnhenry/hydratable 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +32 -0
- package/src/hydratable/demo.mjs +43 -0
- package/src/hydratable/index.mjs +39 -0
- package/src/hydratable/readme.md +7 -0
- package/src/mounts/body.mjs +1 -0
- package/src/mounts/first.mjs +11 -0
- package/src/mounts/last.mjs +11 -0
- package/src/mounts/readme.md +65 -0
- package/src/mounts/unsuitable.mjs +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Henry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# hydratable
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@johnhenry/hydratable)
|
|
4
|
+
[](https://github.com/johnhenry/hydratable/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Full documentation: [opensource.johnhenry.me/hydratable](https://opensource.johnhenry.me/hydratable/)
|
|
8
|
+
|
|
9
|
+
A generic async hydration mixin — `Hydratable(hydrateFn)` returns a
|
|
10
|
+
prototype object you `Object.assign` onto a class's prototype, adding a
|
|
11
|
+
guarded async `hydrate()` method. Also ships `mounts`, the companion this
|
|
12
|
+
package also ships: framework-agnostic DOM mount-point helpers for handing
|
|
13
|
+
React/Vue/Solid's render entrypoint a real element to mount into.
|
|
14
|
+
|
|
15
|
+
No runtime dependencies. No build step — modules ship as source.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @johnhenry/hydratable
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## `hydratable` — a generic async hydration mixin
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import Hydratable from "@johnhenry/hydratable/hydratable/index.mjs";
|
|
27
|
+
|
|
28
|
+
const HPrototype = Hydratable(function ({ finalizer }) {
|
|
29
|
+
// `this` is the instance being hydrated. Define hydration here --
|
|
30
|
+
// this example computes and sets a derived property.
|
|
31
|
+
Object.defineProperty(this, "speed", {
|
|
32
|
+
value: this.distance / this.time,
|
|
33
|
+
writible: false,
|
|
34
|
+
enumerable: true,
|
|
35
|
+
});
|
|
36
|
+
// `finalizer` is optional -- if set, it runs *after* the hydrated flag
|
|
37
|
+
// is set, e.g. to freeze the object once hydration is complete.
|
|
38
|
+
finalizer(Object.freeze);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const object = Object.setPrototypeOf({ time: 1000, distance: 100 }, HPrototype);
|
|
42
|
+
await object.hydrate(); // runs the hydrate function above
|
|
43
|
+
await object.hydrate(); // idempotent -- already hydrated, returns immediately
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`Hydratable(hydrateFn, name = "hydrate")` takes an optional second argument
|
|
47
|
+
to change the generated method's name (e.g. `Hydratable(fn, "init")` adds
|
|
48
|
+
`init()` instead of `hydrate()`).
|
|
49
|
+
|
|
50
|
+
Two guards are built in:
|
|
51
|
+
|
|
52
|
+
- **Double-hydration guard** — a `Symbol("hydrated")` flag is set on the
|
|
53
|
+
instance (not the shared prototype object) after the first successful
|
|
54
|
+
hydration; subsequent calls resolve immediately with `this`, without
|
|
55
|
+
re-running `hydrateFn`.
|
|
56
|
+
- **Direct-prototype-call guard** — calling `hydrate()` directly on the
|
|
57
|
+
object returned by `Hydratable(...)` (rather than on something that
|
|
58
|
+
inherits from it) throws `Error: hydrate(...) must not be called from
|
|
59
|
+
prototype`. This catches the mistake of treating the mixin object itself
|
|
60
|
+
as an instance.
|
|
61
|
+
|
|
62
|
+
See [`src/hydratable/demo.mjs`](src/hydratable/demo.mjs) for a full worked
|
|
63
|
+
example, including both `Object.setPrototypeOf` and `Object.create` styles
|
|
64
|
+
of attaching the mixin.
|
|
65
|
+
|
|
66
|
+
## `mounts` — framework-agnostic DOM mount-point helpers
|
|
67
|
+
|
|
68
|
+
Using `document.body` or one of its direct descendants as a mount point is
|
|
69
|
+
a common pattern in modern JavaScript applications
|
|
70
|
+
([Solid](https://www.solidjs.com/), [Vue](https://vuejs.org/),
|
|
71
|
+
[React](https://reactjs.org/), etc.). `mounts` abstracts that away as
|
|
72
|
+
plain imports, so a render call can hand a real element straight to
|
|
73
|
+
React/Vue/Solid's entrypoint.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { render } from "solid-js/web";
|
|
77
|
+
import Application from "./Solid-Application";
|
|
78
|
+
import body from "@johnhenry/hydratable/mounts/body.mjs";
|
|
79
|
+
render(() => <Application />, body);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import Application from "./Vue-Application";
|
|
84
|
+
import first from "@johnhenry/hydratable/mounts/first.mjs";
|
|
85
|
+
Application.mount(first);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { createRoot } from "react-dom/client";
|
|
90
|
+
import Application from "./React-Application";
|
|
91
|
+
import last from "@johnhenry/hydratable/mounts/last.mjs";
|
|
92
|
+
const root = createRoot(last);
|
|
93
|
+
root.render(Application);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Module | Description |
|
|
97
|
+
|---|---|
|
|
98
|
+
| [`mounts/body.mjs`](src/mounts/body.mjs) | `document.body` itself. Note: some frameworks (including React) warn against mounting directly onto `body` — prefer `first`/`last` instead. |
|
|
99
|
+
| [`mounts/first.mjs`](src/mounts/first.mjs) | `document.body`'s first child, if it's a real, "suitable" element — otherwise a new `div` is created and prepended. |
|
|
100
|
+
| [`mounts/last.mjs`](src/mounts/last.mjs) | `document.body`'s last child, if it's a real, "suitable" element — otherwise a new `div` is created and appended. |
|
|
101
|
+
| [`mounts/unsuitable.mjs`](src/mounts/unsuitable.mjs) | The list of tag names `first`/`last` refuse to reuse as a mount point: `script`, `style`, `link`, `noscript`. |
|
|
102
|
+
|
|
103
|
+
### Gotcha: `first.mjs`/`last.mjs` run at import time, not call time
|
|
104
|
+
|
|
105
|
+
**`mounts/first.mjs` and `mounts/last.mjs` execute real DOM-reading code —
|
|
106
|
+
`window.document.body.firstChild` / `.lastChild`, and potentially
|
|
107
|
+
`document.createElement` + `prepend`/`append` — at module-evaluation time,
|
|
108
|
+
directly in the module body, not inside an exported function.** The
|
|
109
|
+
resolved (or newly created) element is the module's `default` export
|
|
110
|
+
itself, computed once, the moment the module is first imported.
|
|
111
|
+
|
|
112
|
+
This is deliberate, documented behavior, not a bug:
|
|
113
|
+
|
|
114
|
+
- It means `document.body` must already exist and be in the state you
|
|
115
|
+
want inspected/mutated *before* you `import` either module — importing
|
|
116
|
+
it earlier than you intend (e.g. via a bundler hoisting imports, or a
|
|
117
|
+
barrel file) mutates the DOM at that earlier point instead.
|
|
118
|
+
- It also means these two modules are effectively one-shot: importing
|
|
119
|
+
`first.mjs` a second time anywhere in the same module graph returns the
|
|
120
|
+
*same* cached export (ESM modules only evaluate once) — it will not
|
|
121
|
+
re-inspect the DOM or notice if `document.body`'s children changed since
|
|
122
|
+
the first import.
|
|
123
|
+
- `mounts/body.mjs` does the same eager read (`window.document.body`) but
|
|
124
|
+
has no conditional logic, so the only consequence is that it can't be
|
|
125
|
+
imported before `document.body` exists.
|
|
126
|
+
|
|
127
|
+
If you need the resolution logic to run lazily or more than once, don't
|
|
128
|
+
import `first`/`last` directly for that — read their (tiny) source and
|
|
129
|
+
adapt the pattern into a function you call when you actually want it
|
|
130
|
+
evaluated.
|
|
131
|
+
|
|
132
|
+
## Family
|
|
133
|
+
|
|
134
|
+
- Originally extracted from [`johnhenry/lib`](https://github.com/johnhenry/lib)'s
|
|
135
|
+
`js/{mounts,hydratable}/0.0.0/` directories, then briefly part of
|
|
136
|
+
[`@johnhenry/domkit`](https://github.com/johnhenry/domkit) (a toolkit of
|
|
137
|
+
independent DOM/HTML-component modules). Both modules were documented in
|
|
138
|
+
domkit as companions to [`@johnhenry/domable`](https://github.com/johnhenry/domable)'s
|
|
139
|
+
DOM⇄React interop functions (`domToReact`/`reactToDom`) — convert a tree
|
|
140
|
+
to/from a React-element-shaped object with domable, then hand the
|
|
141
|
+
resulting rendered or converted tree to one of this package's `mounts`
|
|
142
|
+
helpers (or wrap the object in `hydratable`'s mixin) to actually mount it
|
|
143
|
+
into the page. Extracted again, out of domkit, into this standalone
|
|
144
|
+
package — the two modules form a real, coherent cluster on their own
|
|
145
|
+
(both concerned with getting a rendered/hydrated tree live in the page),
|
|
146
|
+
distinct from domkit's custom-element and shadow-DOM primitives.
|
|
147
|
+
- [`@johnhenry/domkit`](https://github.com/johnhenry/domkit) — the
|
|
148
|
+
toolkit these two modules used to live in.
|
|
149
|
+
- [`@johnhenry/domable`](https://github.com/johnhenry/domable) — DOM ⇄
|
|
150
|
+
text ⇄ React conversion primitives; `domToReact`/`reactToDom` pair
|
|
151
|
+
naturally with this package's `mounts`/`hydratable`, as described above.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johnhenry/hydratable",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A generic async hydration mixin, plus framework-agnostic DOM mount-point helpers for handing a mount element to React/Vue/Solid.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./*": "./src/*"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node scripts/check-syntax.mjs && node test/import.test.mjs"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"hydration",
|
|
17
|
+
"hydrate",
|
|
18
|
+
"dom",
|
|
19
|
+
"mount",
|
|
20
|
+
"ssr"
|
|
21
|
+
],
|
|
22
|
+
"author": "John Henry",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/johnhenry/hydratable.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://opensource.johnhenry.me/hydratable/",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=26.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Hydratable from "./index.mjs";
|
|
2
|
+
let index = 0;
|
|
3
|
+
const output = async (object) => {
|
|
4
|
+
console.log(index++);
|
|
5
|
+
console.log("initial object", object);
|
|
6
|
+
console.log("hydrated object", await object.hydrate()); // hydrate
|
|
7
|
+
await object.hydrate();
|
|
8
|
+
console.log("idempotency", object); // idempotent
|
|
9
|
+
console.log("keys", Object.keys(object));
|
|
10
|
+
};
|
|
11
|
+
const HPrototype = Hydratable(function ({ finalizer }) {
|
|
12
|
+
// here, hydration is defained as calculating and setting the speed property
|
|
13
|
+
Object.defineProperty(this, "speed", {
|
|
14
|
+
value: this.distance / this.time,
|
|
15
|
+
writible: false,
|
|
16
|
+
enumerable: true,
|
|
17
|
+
});
|
|
18
|
+
// also, we use the 'finalizer' optional argument to apply Object.freeze to the object AFTER setting the HYDRATED property
|
|
19
|
+
finalizer(Object.freeze);
|
|
20
|
+
});
|
|
21
|
+
// create object with minimal constraints
|
|
22
|
+
const object = Object.setPrototypeOf(
|
|
23
|
+
{
|
|
24
|
+
time: 1000,
|
|
25
|
+
distance: 100,
|
|
26
|
+
},
|
|
27
|
+
HPrototype
|
|
28
|
+
);
|
|
29
|
+
await output(object);
|
|
30
|
+
// create object with maximal constraints
|
|
31
|
+
const object1 = Object.create(HPrototype, {
|
|
32
|
+
time: {
|
|
33
|
+
value: 1000,
|
|
34
|
+
writible: false,
|
|
35
|
+
enumerable: true,
|
|
36
|
+
},
|
|
37
|
+
distance: {
|
|
38
|
+
value: 100,
|
|
39
|
+
writible: false,
|
|
40
|
+
enumerable: true,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
await output(object1);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const HYDRATED = Symbol("hydrated");
|
|
2
|
+
const Hydratable = (hydrate, name = "hydrate") => {
|
|
3
|
+
const PRTOTOTYPE = {
|
|
4
|
+
async [name]() {
|
|
5
|
+
// should not be called directly on prototype object,
|
|
6
|
+
// but rather from inherritor
|
|
7
|
+
if (this.hasOwnProperty(name)) {
|
|
8
|
+
throw new Error(`${name}(...) must not be called from prototype`);
|
|
9
|
+
}
|
|
10
|
+
// return object if alreay hydrated
|
|
11
|
+
if (this[HYDRATED]) {
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
// finalize may be set during hydration
|
|
15
|
+
let finalize;
|
|
16
|
+
// perform hydratoion
|
|
17
|
+
await hydrate.call(this, {
|
|
18
|
+
finalizer: (func) => {
|
|
19
|
+
finalize = func;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
// set HYDRATED flag
|
|
23
|
+
Object.defineProperty(this, HYDRATED, {
|
|
24
|
+
configurable: false,
|
|
25
|
+
value: true,
|
|
26
|
+
writible: false,
|
|
27
|
+
});
|
|
28
|
+
// apply finalize after hydration
|
|
29
|
+
if (typeof finalize === "function") {
|
|
30
|
+
await finalize.call(this, this);
|
|
31
|
+
}
|
|
32
|
+
// return hydrated object
|
|
33
|
+
return this;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return PRTOTOTYPE;
|
|
37
|
+
};
|
|
38
|
+
export default Hydratable;
|
|
39
|
+
export { HYDRATED };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default window.document.body;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import unsuitable from "./unsuitable.mjs";
|
|
2
|
+
let target = window.document.body.firstChild;
|
|
3
|
+
if (
|
|
4
|
+
!target ||
|
|
5
|
+
target.nodeType !== Node.ELEMENT_NODE ||
|
|
6
|
+
unsuitable.includes(target.tagName.toLowerCase())
|
|
7
|
+
) {
|
|
8
|
+
target = window.document.createElement("div");
|
|
9
|
+
window.document.body.prepend(target);
|
|
10
|
+
}
|
|
11
|
+
export default target;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import unsuitable from "./unsuitable.mjs";
|
|
2
|
+
let target = window.document.body.lastChild;
|
|
3
|
+
if (
|
|
4
|
+
!target ||
|
|
5
|
+
target.nodeType !== Node.ELEMENT_NODE ||
|
|
6
|
+
unsuitable.includes(target.tagName.toLowerCase())
|
|
7
|
+
) {
|
|
8
|
+
target = window.document.createElement("div");
|
|
9
|
+
window.document.body.append(target);
|
|
10
|
+
}
|
|
11
|
+
export default target;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Mounts
|
|
2
|
+
|
|
3
|
+
Using the body or its direct decendents
|
|
4
|
+
as mount points is a common pattern
|
|
5
|
+
in modern javascript applications ([Solid](https://www.solidjs.com/), [Vue](https://vuejs.org/), [React](https://reactjs.org/), etc.).
|
|
6
|
+
|
|
7
|
+
We can declaratively abstract this away as imports.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
### Body
|
|
12
|
+
|
|
13
|
+
Use body element as a mount point.
|
|
14
|
+
|
|
15
|
+
Note: Some applications including react, warn against using the body element as a mount point.
|
|
16
|
+
Use 'first' or 'last' instead.
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import { render } from "solid-js/web";
|
|
20
|
+
import Application from "./Soild-Application";
|
|
21
|
+
import body from "mounts/body.mjs";
|
|
22
|
+
render(() => <Application />, body);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### First
|
|
26
|
+
|
|
27
|
+
Use body's first child as a mount point.
|
|
28
|
+
|
|
29
|
+
Creates and prepends a new 'div' element
|
|
30
|
+
if child is "unsuitable" or non-existent.
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
import Application from "./Vue-Application";
|
|
34
|
+
import first from "mounts/first.mjs";
|
|
35
|
+
Application.mount(first);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Last
|
|
39
|
+
|
|
40
|
+
Use body's last child as a mount point.
|
|
41
|
+
|
|
42
|
+
Creates and appends a new 'div' element
|
|
43
|
+
if child is "unsuitable" or non-existent.
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
import { createRoot } from "react";
|
|
47
|
+
import Application from "./React-Application";
|
|
48
|
+
import last from "mounts/last.mjs";
|
|
49
|
+
const root = createRoot(last);
|
|
50
|
+
root.render(Application);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Unsuitable elements
|
|
54
|
+
|
|
55
|
+
The following elements are considered "unsuitable" for mounting:
|
|
56
|
+
|
|
57
|
+
- script
|
|
58
|
+
- style
|
|
59
|
+
- link
|
|
60
|
+
- noscript
|
|
61
|
+
|
|
62
|
+
## See also
|
|
63
|
+
|
|
64
|
+
- [`@johnhenry/domable`](https://github.com/johnhenry/domable)'s `domToReact`/`reactToDom` — converting between real DOM and React-element-shaped objects (domkit doesn't vendor its own copy of these; domable's is the maintained one)
|
|
65
|
+
- [hydratable](../hydratable/readme.md) — a generic hydration mixin
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default ["script", "style", "link", "noscript"];
|