@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 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
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fhydratable.svg)](https://www.npmjs.com/package/@johnhenry/hydratable)
4
+ [![CI](https://github.com/johnhenry/hydratable/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/hydratable/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fhydratable.svg)](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,7 @@
1
+ # Hydratable
2
+
3
+ Hydratable adds hydration functionality to objects in your prototype chain.
4
+
5
+ ## See also
6
+
7
+ - [mounts](../mounts/readme.md) — framework-agnostic DOM mount-point helpers
@@ -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"];