@zoijs/core 1.5.0 → 1.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to Zoijs are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/), and Zoijs follows
5
5
  [Semantic Versioning](https://semver.org/) (see `VERSIONING.md`).
6
6
 
7
+ ## [1.6.0] — 2026-06-27
8
+
9
+ ### Added
10
+ - **Hydration — `mount(component, target, { hydrate: true })`.** The client now
11
+ **adopts** server-rendered DOM in place instead of re-creating it: with `hydrate`,
12
+ `mount` reuses the existing elements inside `target` exactly — never cloning or
13
+ replacing them — and attaches event handlers and reactive attribute bindings to
14
+ those live nodes. Each dynamic child slot's server content is cleared (back to the
15
+ `<!--zoijs:[-->` start marker that [`@zoijs/ssr`](https://www.npmjs.com/package/@zoijs/ssr)
16
+ emits) and re-rendered in place; since the values match the server, there is no
17
+ visible change and no flash. This is the client half of full SSR — pair it with
18
+ `renderToString(component, { hydratable: true })`. The default `mount` path is
19
+ byte-for-byte unchanged (the option is additive), and the nine-function main surface
20
+ is the same. `@zoijs/ssr` re-exports this as `hydrate()`. See
21
+ [RFC 0008](docs/rfcs/0008-ssr.md).
22
+
7
23
  ## [1.5.0] — 2026-06-26
8
24
 
9
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoijs/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Zoijs — a beginner-friendly, no-build, no-Virtual-DOM frontend framework. Plain HTML, CSS, and JavaScript.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/core/mount.js CHANGED
@@ -11,18 +11,25 @@ import { createOwner, runWithOwner, disposeOwner } from "../reactivity/owner.js"
11
11
  /**
12
12
  * @param {Function|object} component a component function, or an html() result
13
13
  * @param {Element|string} target a DOM element or a CSS selector
14
+ * @param {{ hydrate?: boolean }} [options] with `hydrate: true`, adopt the
15
+ * server-rendered DOM already inside `target` instead of replacing it — the
16
+ * elements/attributes/events are reused in place (used by @zoijs/ssr's
17
+ * `hydrate()`). The returned unmount() disposes everything either way.
14
18
  * @returns {Function} unmount
15
19
  */
16
- export function mount(component, target) {
20
+ export function mount(component, target, options) {
17
21
  const el = resolveTarget(target);
18
22
  const owner = createOwner();
23
+ const hydrate = !!(options && options.hydrate);
19
24
 
20
25
  let node;
21
26
  runWithOwner(owner, () => {
22
27
  const result = typeof component === "function" ? component() : component;
23
- node = render(result).node;
28
+ // Hydration binds to el's existing children in place; a fresh mount builds a
29
+ // detached fragment we then swap in.
30
+ node = render(result, hydrate ? el : undefined).node;
24
31
  });
25
- el.replaceChildren(node);
32
+ if (!hydrate) el.replaceChildren(node);
26
33
 
27
34
  return () => {
28
35
  disposeOwner(owner);
@@ -24,15 +24,21 @@ const noop = () => {};
24
24
 
25
25
  /**
26
26
  * @param {{ template: HTMLTemplateElement, parts: object[], values: any[] }} result
27
- * @returns {{ node: DocumentFragment, dispose: Function }}
27
+ * @param {Element} [hydrateRoot] when given, bind to this EXISTING server-rendered
28
+ * subtree in place (no clone) instead of building fresh DOM — see hydration below.
29
+ * @returns {{ node: Node, dispose: Function }}
28
30
  */
29
- export function render(result) {
31
+ export function render(result, hydrateRoot) {
30
32
  const owner = createOwner(); // nested under the active owner
31
- const fragment = result.template.content.cloneNode(true);
33
+ // Hydration adopts the server DOM in place — elements, attributes, and events are
34
+ // reused, never re-created; each dynamic slot is cleared + re-rendered (same
35
+ // values → no visible change). Otherwise build a fresh clone.
36
+ const hydrating = hydrateRoot != null;
37
+ const fragment = hydrating ? hydrateRoot : result.template.content.cloneNode(true);
32
38
  const { parts, values } = result;
33
39
 
34
- // Collect every part's target node on the PRISTINE clone first (document
35
- // order), so a binding that inserts children can't shadow a later part.
40
+ // Collect every part's target node first (document order), so a binding that
41
+ // inserts children can't shadow a later part.
36
42
  const nodes = collectNodes(fragment, parts, result.hasElements);
37
43
 
38
44
  runWithOwner(owner, () => {
@@ -42,6 +48,7 @@ export function render(result) {
42
48
  if (!node) continue;
43
49
 
44
50
  if (part.type === "child") {
51
+ if (hydrating) clearHydratedSlot(node); // drop this slot's server content
45
52
  bindChild(node, values[part.hole]);
46
53
  } else {
47
54
  node.removeAttribute("data-zoijs-bind");
@@ -53,6 +60,20 @@ export function render(result) {
53
60
  return { node: fragment, dispose: () => disposeOwner(owner) };
54
61
  }
55
62
 
63
+ // Remove a hydrated slot's server content: the nodes before its anchor, back
64
+ // through the slot's `<!--zoijs:[-->` start marker (emitted by @zoijs/ssr). Static
65
+ // text outside the slot is preserved; the normal binding then renders fresh content
66
+ // in the same place. No start marker → leave the DOM as-is (not a hydratable slot).
67
+ const isSlotStart = (n) => n && n.nodeType === 8 && n.data === "zoijs:[";
68
+ function clearHydratedSlot(anchor) {
69
+ let start = anchor.previousSibling;
70
+ while (start && !isSlotStart(start)) start = start.previousSibling;
71
+ if (!start) return;
72
+ let n;
73
+ while ((n = anchor.previousSibling) !== start) n.remove();
74
+ start.remove();
75
+ }
76
+
56
77
  // Match parts to nodes by document order: a child part ↔ the next marker comment,
57
78
  // an element part ↔ the next element carrying data-zoijs-bind. Unique markers make
58
79
  // this collision-proof across nested templates and list items.
package/src/index.d.ts CHANGED
@@ -66,11 +66,26 @@ export type Ref<E extends Element = Element> = (element: E) => void | (() => voi
66
66
  */
67
67
  export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
68
68
 
69
+ /** Options for {@link mount}. */
70
+ export interface MountOptions {
71
+ /**
72
+ * Adopt the server-rendered DOM already inside `target` (from `@zoijs/ssr`'s
73
+ * `renderToString(..., { hydratable: true })`) instead of replacing it: elements,
74
+ * attributes, and events are reused in place. Used by `@zoijs/ssr`'s `hydrate()`.
75
+ */
76
+ hydrate?: boolean;
77
+ }
78
+
69
79
  /**
70
80
  * Render a component (or a template) into a DOM element or CSS selector.
71
81
  * Returns an `unmount()` that detaches the DOM and disposes all reactivity.
82
+ * With `{ hydrate: true }`, binds to the existing server-rendered DOM in place.
72
83
  */
73
- export function mount(component: Component | TemplateResult, target: Element | string): () => void;
84
+ export function mount(
85
+ component: Component | TemplateResult,
86
+ target: Element | string,
87
+ options?: MountOptions
88
+ ): () => void;
74
89
 
75
90
  /**
76
91
  * Create a reactive value.