azoxjs 1.2.0 → 1.3.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/README.md CHANGED
@@ -11,7 +11,7 @@ third-party CLI dependencies, no borrowed syntax from React, Vue, or
11
11
  Next.js. It compiles `.azox` components directly into fine-grained,
12
12
  signal-driven DOM updates.
13
13
 
14
- > Status: stable (v1.2.0). The template syntax, the reactivity exports
14
+ > Status: stable (v1.3.0). The template syntax, the reactivity exports
15
15
  > and the shape of the build output will not change without a 2.0. What
16
16
  > Azox does **not** do is listed in
17
17
  > [Limitations](https://azox.dev/docs/limitations) — those are stopping
@@ -7,9 +7,27 @@
7
7
  // the browser, which is what makes the playground possible. Anything
8
8
  // that needs to know about paths on disk belongs in build.js.
9
9
 
10
+ // Control-flow markers carry a label. An empty comment serialises to
11
+ // <!---->, so two adjacent blocks produce four identical nodes and a
12
+ // walker adopting server markup cannot tell a start from an end, nor
13
+ // one block from the next. The server emits the same pair.
14
+ //
15
+ // See docs/hydration.md.
16
+ export const BLOCK_START = '[';
17
+ export const BLOCK_END = ']';
18
+
10
19
  let uid = 0;
11
20
  const nextId = () => `_el${uid++}`;
12
21
 
22
+ // Node variable -> the binding holding what the cursor handed back for
23
+ // it, or absent when the node was created outright. Whoever appends a
24
+ // node consults this: an adopted node is already in place, and
25
+ // appending an existing child detaches and re-attaches it, which blurs
26
+ // it and loses the reader's focus. Set per compile, and cleared with
27
+ // the variable counter — a page compiles twice when it adopts, and the
28
+ // second pass reuses the same names.
29
+ let adoptedOf = new Map();
30
+
13
31
  // Tags that open an SVG document fragment. Everything inside one is in
14
32
  // the SVG namespace too, which is threaded down as `inSvg` — <circle>
15
33
  // and <path> carry no hint of their own.
@@ -86,13 +104,42 @@ export function compileToModule(
86
104
  { runtimeSpecifier, rewriteImports, inlineModules, routeParams }
87
105
  ) {
88
106
  uid = 0;
107
+ adoptedOf = new Map();
89
108
  // Values the build resolved: their bindings need no effect, which is
90
109
  // what lets a page whose only "dynamic" text is a version number ship
91
110
  // as a static page.
92
111
  constantValues = inlineModules ?? {};
93
112
  constantNames = new Set(Object.keys(constantValues));
94
- const statements = [];
95
- const rootVar = emitNode(ast.markup, statements, 'root');
113
+ let statements = [];
114
+ let rootVar = emitNode(ast.markup, statements, 'root');
115
+
116
+ // Adoption reuses the nodes the server sent instead of rebuilding
117
+ // them, which is what preserves focus, a caret position and an open
118
+ // <details> across hydration.
119
+ //
120
+ // Control flow is excluded for now. An <if> or <each> creates its
121
+ // marker pair and its rows fresh on every run, so a page mixing
122
+ // adopted markup with a rebuilt block would bind effects across two
123
+ // generations of nodes — worse than the wholesale rebuild it
124
+ // replaces. Pages carrying a block keep that rebuild until the
125
+ // walker can adopt between the markers too; see docs/hydration.md.
126
+ const hasControlFlow = statements.some((line) => line.includes('createComment('));
127
+ // A static page ships no hydration block, so no cursor can ever reach
128
+ // its render(): adopting there would import the walker for nothing.
129
+ // Decided from this pass, whose effect and listener lines are the same
130
+ // ones the second pass emits.
131
+ const staticFirstPass = !statements.some(
132
+ (line) => line.startsWith('effect(') || line.includes('.addEventListener(')
133
+ );
134
+ const adopts = !hasControlFlow && !staticFirstPass;
135
+ if (adopts) {
136
+ // Emitted again from a clean slate: the cursor changes almost every
137
+ // creation site, and reusing the first pass's variable counter
138
+ // would leave gaps in the names.
139
+ uid = 0;
140
+ statements = [];
141
+ rootVar = emitNode(ast.markup, statements, 'root', false, '_cursor');
142
+ }
96
143
 
97
144
  let script = dropComponentImports(ast.script);
98
145
  script = resolveRouteDeclarations(script, routeParams);
@@ -125,6 +172,10 @@ export function compileToModule(
125
172
  // module needs dispose as well as effect.
126
173
  const needsDispose = statements.some((line) => line.includes('dispose('));
127
174
  const needsUntracked = statements.some((line) => line.includes('untracked('));
175
+ const needsAdopt = statements.some((line) => line.includes('adopt('));
176
+ // The root is adopted from the mount the same way children are, so it
177
+ // must not be re-appended either.
178
+ const rootTaken = adoptedOf.get(rootVar);
128
179
 
129
180
  const { imports, body } = mergeImports(
130
181
  script,
@@ -132,7 +183,8 @@ export function compileToModule(
132
183
  runtimeSpecifier,
133
184
  needsDispose,
134
185
  inlineModules,
135
- needsUntracked
186
+ needsUntracked,
187
+ needsAdopt
136
188
  );
137
189
 
138
190
  // Narrowed to what the module actually reads, so importing
@@ -143,12 +195,12 @@ export function compileToModule(
143
195
  ${imports}
144
196
  ${inlined}${body}
145
197
 
146
- export function render(mount) {
198
+ export function render(mount${adopts ? ', _cursor = null' : ''}) {
147
199
  ${statements.map((line) => ' ' + line).join('\n')}
148
- mount.appendChild(${rootVar});
200
+ ${rootTaken ? ` if (!${rootTaken}) mount.appendChild(${rootVar});` : ` mount.appendChild(${rootVar});`}
149
201
  return ${rootVar};
150
202
  }
151
- ${isStatic ? staticNote() : hydrateBlock()}`.trimStart();
203
+ ${isStatic ? staticNote() : hydrateBlock(adopts)}`.trimStart();
152
204
  }
153
205
 
154
206
  // Collects every import the module needs into one set of statements,
@@ -164,7 +216,8 @@ function mergeImports(
164
216
  runtimeSpecifier,
165
217
  needsDispose = false,
166
218
  inlineModules = null,
167
- needsUntracked = false
219
+ needsUntracked = false,
220
+ needsAdopt = false
168
221
  ) {
169
222
  const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
170
223
  .map((m) => m[1].trim())
@@ -229,6 +282,7 @@ function mergeImports(
229
282
  record(`import { effect } from '${runtimeSpecifier}';`);
230
283
  if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
231
284
  if (needsUntracked) record(`import { untracked } from '${runtimeSpecifier}';`);
285
+ if (needsAdopt) record(`import { adopt } from '${runtimeSpecifier}';`);
232
286
 
233
287
  const lines = [
234
288
  ...[...named].map(([specifier, bindings]) => {
@@ -379,7 +433,18 @@ function staticNote() {
379
433
  `;
380
434
  }
381
435
 
382
- function hydrateBlock() {
436
+ function hydrateBlock(adopts = false) {
437
+ const mountLines = adopts
438
+ ? ` // Adopting: the walker takes each node the server already sent,
439
+ // so focus, a caret position and an open <details> survive. Anything
440
+ // that does not match is created instead, and anything left over is
441
+ // removed — a stale page costs the rebuild we used to do always.
442
+ const _cursor = adopt(mount);
443
+ render(mount, _cursor);
444
+ _cursor.done();`
445
+ : ` mount.innerHTML = '';
446
+ render(mount);`;
447
+
383
448
  return `
384
449
  // Hydrate: the SSR markup is already on the page, so clear it and
385
450
  // mount the reactive version in its place.
@@ -391,8 +456,7 @@ function hydrateBlock() {
391
456
  // navigation, so one listener covers both.
392
457
  if (typeof document !== 'undefined') {
393
458
  const mount = document.querySelector('[data-azox-root]') ?? document.body;
394
- mount.innerHTML = '';
395
- render(mount);
459
+ ${mountLines}
396
460
  // Guarded: the emitted module is also run against minimal DOM stubs —
397
461
  // in tests, and anywhere rendering happens outside a browser — where
398
462
  // CustomEvent and dispatchEvent need not exist.
@@ -407,11 +471,11 @@ if (typeof document !== 'undefined') {
407
471
  `;
408
472
  }
409
473
 
410
- function emitNode(node, statements, fallbackVar, inSvg = false) {
474
+ function emitNode(node, statements, fallbackVar, inSvg = false, cursor = null) {
411
475
  if (!node) return 'null';
412
476
 
413
477
  if (node.type === 'text') {
414
- return emitText(node, statements, fallbackVar);
478
+ return emitText(node, statements, fallbackVar, cursor);
415
479
  }
416
480
 
417
481
  // A fragment (from <slot />) has no element of its own; it wraps
@@ -419,7 +483,9 @@ function emitNode(node, statements, fallbackVar, inSvg = false) {
419
483
  if (node.type === 'fragment') {
420
484
  const varName = nextId();
421
485
  statements.push(`const ${varName} = document.createDocumentFragment();`);
422
- appendChildren(varName, node.children, statements, fallbackVar, inSvg);
486
+ // A fragment owns no node of its own, so its children are adopted
487
+ // against the parent's cursor rather than a new one.
488
+ appendChildren(varName, node.children, statements, fallbackVar, inSvg, cursor, true);
423
489
  return varName;
424
490
  }
425
491
 
@@ -433,17 +499,35 @@ function emitNode(node, statements, fallbackVar, inSvg = false) {
433
499
  // HTML element, so an inline <svg> compiled to something the browser
434
500
  // laid out as an unknown HTML tag: present in the DOM, 0×0 on screen.
435
501
  const svg = inSvg || SVG_TAGS.has(node.name);
436
- statements.push(
437
- svg
438
- ? `const ${varName} = document.createElementNS("http://www.w3.org/2000/svg", ${JSON.stringify(node.name)});`
439
- : `const ${varName} = document.createElement(${JSON.stringify(node.name)});`
440
- );
502
+ // With a cursor, take the node the server already sent when it
503
+ // matches. A mismatch yields null and this falls back to creating, so
504
+ // a stale page costs the work we do today rather than breaking.
505
+ const create = svg
506
+ ? `document.createElementNS("http://www.w3.org/2000/svg", ${JSON.stringify(node.name)})`
507
+ : `document.createElement(${JSON.stringify(node.name)})`;
508
+
509
+ // Optional call: render() is exported, and a direct caller passes no
510
+ // cursor at all. Without the guard the root line would throw on null
511
+ // instead of falling back to creating the node.
512
+ //
513
+ // The adopted node is kept in its own binding so whoever appends this
514
+ // one can tell the two cases apart: an adopted node is already in
515
+ // place, and re-appending it would detach and re-attach it, which
516
+ // blurs it. See docs/hydration.md.
517
+ if (cursor) {
518
+ const taken = nextId();
519
+ statements.push(`const ${taken} = ${cursor}?.next(${JSON.stringify(node.name)});`);
520
+ statements.push(`const ${varName} = ${taken} ?? ${create};`);
521
+ adoptedOf.set(varName, taken);
522
+ } else {
523
+ statements.push(`const ${varName} = ${create};`);
524
+ }
441
525
 
442
526
  for (const [key, attr] of Object.entries(node.attrs)) {
443
527
  emitAttr(varName, key, attr, statements, node.name);
444
528
  }
445
529
 
446
- appendChildren(varName, node.children, statements, fallbackVar, svg);
530
+ appendChildren(varName, node.children, statements, fallbackVar, svg, cursor);
447
531
 
448
532
  return varName;
449
533
  }
@@ -495,8 +579,8 @@ function emitControlBlock(statements, buildBody, sourceExpr, renderCall) {
495
579
 
496
580
  const holder = nextId();
497
581
 
498
- statements.push(`const ${start} = document.createComment('');`);
499
- statements.push(`const ${end} = document.createComment('');`);
582
+ statements.push(`const ${start} = document.createComment('${BLOCK_START}');`);
583
+ statements.push(`const ${end} = document.createComment('${BLOCK_END}');`);
500
584
 
501
585
  // The markers go into their own fragment straight away, so they
502
586
  // always have a parent to insert into. Waiting for the page to be
@@ -561,8 +645,8 @@ function emitKeyedEach(node, statements) {
561
645
  const rows = nextId();
562
646
  const holder = nextId();
563
647
 
564
- statements.push(`const ${start} = document.createComment('');`);
565
- statements.push(`const ${end} = document.createComment('');`);
648
+ statements.push(`const ${start} = document.createComment('${BLOCK_START}');`);
649
+ statements.push(`const ${end} = document.createComment('${BLOCK_END}');`);
566
650
  statements.push(`const ${holder} = document.createDocumentFragment();`);
567
651
  statements.push(`${holder}.append(${start}, ${end});`);
568
652
 
@@ -628,8 +712,10 @@ function emitKeyedEach(node, statements) {
628
712
  statements.push(` ${rows}.set(_key, _row);`);
629
713
  statements.push(` }`);
630
714
  statements.push(``);
631
- statements.push(` // Moving a node that is already in place is a no-op in`);
632
- statements.push(` // the DOM, so ordering costs nothing when nothing moved.`);
715
+ statements.push(` // insertBefore detaches and re-attaches, even when the node`);
716
+ statements.push(` // is already where it belongs — so this re-seeds the row and`);
717
+ statements.push(` // would blur anything focused inside it. Harmless while rows`);
718
+ statements.push(` // are built fresh, and the thing to fix when they are adopted.`);
633
719
  statements.push(` for (const _node of _row.nodes) _parent.insertBefore(_node, ${end});`);
634
720
  statements.push(` _i++;`);
635
721
  statements.push(` }`);
@@ -702,11 +788,48 @@ function emitIf(node, statements) {
702
788
  );
703
789
  }
704
790
 
705
- function appendChildren(parentVar, children, statements, fallbackVar, inSvg = false) {
791
+ function appendChildren(
792
+ parentVar,
793
+ children,
794
+ statements,
795
+ fallbackVar,
796
+ inSvg = false,
797
+ cursor = null,
798
+ reuseCursor = false
799
+ ) {
800
+ // Each element walks its own children, so a nested cursor is opened
801
+ // for it. A node the cursor handed back is already in place and must
802
+ // not be appended: appending an existing child detaches and
803
+ // re-attaches it, which blurs it and loses the reader's focus. Only a
804
+ // created node is appended. See docs/hydration.md.
805
+ //
806
+ // A fragment is the exception: it owns no node in the document, so
807
+ // there is nothing to open a cursor against. Its children continue
808
+ // the parent's walk, and the parent closes it.
809
+ //
810
+ // Nothing to walk: a void element like <input> would otherwise open a
811
+ // cursor and close it again without ever calling next().
812
+ if (!children.length) return;
813
+
814
+ let inner = reuseCursor ? cursor : null;
815
+ if (!reuseCursor && cursor) {
816
+ inner = nextId();
817
+ statements.push(`const ${inner} = adopt(${parentVar});`);
818
+ }
819
+
706
820
  for (const child of children) {
707
- const childVar = emitNode(child, statements, fallbackVar, inSvg);
708
- if (childVar !== 'null') statements.push(`${parentVar}.appendChild(${childVar});`);
821
+ const childVar = emitNode(child, statements, fallbackVar, inSvg, inner);
822
+ if (childVar === 'null') continue;
823
+ const taken = adoptedOf.get(childVar);
824
+ statements.push(
825
+ taken
826
+ ? `if (!${taken}) ${parentVar}.appendChild(${childVar});`
827
+ : `${parentVar}.appendChild(${childVar});`
828
+ );
709
829
  }
830
+
831
+ // Anything the server sent that the module did not claim is stale.
832
+ if (inner && !reuseCursor) statements.push(`${inner}.done();`);
710
833
  }
711
834
 
712
835
  // createTextNode takes text, not markup, so an entity the author
@@ -732,7 +855,7 @@ function foldLiteralParts(parts) {
732
855
  });
733
856
  }
734
857
 
735
- function emitText(node, statements, fallbackVar) {
858
+ function emitText(node, statements, fallbackVar, cursor = null) {
736
859
  const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
737
860
  node = { ...node, parts: foldConstantParts(foldLiteralParts(node.parts)) };
738
861
 
@@ -740,13 +863,29 @@ function emitText(node, statements, fallbackVar) {
740
863
  if (node.parts.every(isFixed)) {
741
864
  const value = node.parts.map(textValue).join('');
742
865
  const varName = nextId();
743
- statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
866
+ if (cursor) {
867
+ const taken = nextId();
868
+ statements.push(`const ${taken} = ${cursor}?.next(null);`);
869
+ statements.push(
870
+ `const ${varName} = ${taken} ?? document.createTextNode(${JSON.stringify(value)});`
871
+ );
872
+ adoptedOf.set(varName, taken);
873
+ } else {
874
+ statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
875
+ }
744
876
  return varName;
745
877
  }
746
878
 
747
879
  // Dynamic text: one text node, one effect that rewrites its data.
748
880
  const varName = nextId();
749
- statements.push(`const ${varName} = document.createTextNode('');`);
881
+ if (cursor) {
882
+ const taken = nextId();
883
+ statements.push(`const ${taken} = ${cursor}?.next(null);`);
884
+ statements.push(`const ${varName} = ${taken} ?? document.createTextNode('');`);
885
+ adoptedOf.set(varName, taken);
886
+ } else {
887
+ statements.push(`const ${varName} = document.createTextNode('');`);
888
+ }
750
889
  const expr = node.parts
751
890
  .map((part) => (isFixed(part) ? JSON.stringify(textValue(part)) : `String(${part.expr})`))
752
891
  .join(' + ');
@@ -815,6 +954,7 @@ const DOM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']
815
954
  function emitAttr(varName, key, attr, statements, tagName) {
816
955
  if (key.startsWith('on:')) {
817
956
  const event = key.slice(3);
957
+ assertHandler(key, attr.expr);
818
958
  statements.push(`${varName}.addEventListener(${JSON.stringify(event)}, ${attr.expr});`);
819
959
  return;
820
960
  }
@@ -840,6 +980,43 @@ function emitAttr(varName, key, attr, statements, tagName) {
840
980
  statements.push(`effect(() => { ${varName}.setAttribute(${JSON.stringify(key)}, String(${attr.expr})); });`);
841
981
  }
842
982
 
983
+ // A listener is the expression itself, so `on:click={n.set(1)}` passes
984
+ // addEventListener whatever the call returned — and the call already ran
985
+ // while the page was being built. The button then appears to fire once on
986
+ // load and never again, with no error anywhere. Caught here instead.
987
+ //
988
+ // Only a call at the top level is rejected. A handler is far more often a
989
+ // name (`handler`, `obj.method`, `fns[i]`) or an arrow whose body happens
990
+ // to contain a call, and all of those have to keep working.
991
+ function assertHandler(key, rawExpr) {
992
+ const expr = rawExpr.trim();
993
+
994
+ if (/=>/.test(expr)) return;
995
+ if (/^(async\s+)?function\b/.test(expr)) return;
996
+
997
+ const opening = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]*\])*\(/.exec(expr);
998
+ if (!opening || !expr.endsWith(')')) return;
999
+
1000
+ // Balanced from the first `(`: only a call that closes at the very end
1001
+ // is the whole expression. `f(1) || g` is something else, and left
1002
+ // alone.
1003
+ let depth = 0;
1004
+ for (let i = opening[0].length - 1; i < expr.length; i++) {
1005
+ if (expr[i] === '(') depth++;
1006
+ else if (expr[i] === ')') {
1007
+ depth--;
1008
+ if (depth === 0 && i !== expr.length - 1) return;
1009
+ }
1010
+ }
1011
+
1012
+ const name = opening[0].slice(0, -1);
1013
+ throw new Error(
1014
+ `Azox: ${key}={${expr}} calls ${name}() while the page is built, and ` +
1015
+ `passes the result as the listener — ` +
1016
+ `write ${key}={() => ${expr}} to call it on the event instead`
1017
+ );
1018
+ }
1019
+
843
1020
  // Two-way binding: the element shows the signal, and the signal follows
844
1021
  // the element. Writing it by hand means a value= and an on:input= that
845
1022
  // have to agree, and getting the event or the property wrong is easy —
package/core/index.d.ts CHANGED
@@ -11,9 +11,11 @@ export {
11
11
  untracked,
12
12
  onMount,
13
13
  onCleanup,
14
+ adopt,
14
15
  type Signal,
15
16
  type ReadonlySignal,
16
17
  type EffectHandle,
18
+ type AdoptCursor,
17
19
  } from './reactivity/signal.js';
18
20
 
19
21
  export { parseAzox, compileToModule } from './compiler/index.js';
package/core/index.js CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  untracked,
12
12
  onMount,
13
13
  onCleanup,
14
+ adopt,
14
15
  } from './reactivity/signal.js';
15
16
  export { parseAzox } from './compiler/parser.js';
16
17
  export { compileToModule } from './compiler/compileToJs.js';
@@ -86,3 +86,21 @@ export declare function onMount(fn: () => void | (() => void)): void;
86
86
  * top level of a page — nothing ever removes it.
87
87
  */
88
88
  export declare function onCleanup(fn: () => void): void;
89
+
90
+ /** A cursor over server-rendered nodes, used by hydration. */
91
+ export interface AdoptCursor {
92
+ /**
93
+ * The next node if it matches — a tag name, null for text, or
94
+ * '#comment' for a control-flow marker.
95
+ */
96
+ next(expect: string | null): Node | null;
97
+ /** Removes anything the module did not claim. */
98
+ done(): void;
99
+ }
100
+
101
+ /**
102
+ * Walks a parent's existing children so hydration can bind to them
103
+ * rather than replacing them. Returns a cursor that yields null on any
104
+ * mismatch, at which point the caller creates the node instead.
105
+ */
106
+ export declare function adopt(parent: Node | null): AdoptCursor;
@@ -181,3 +181,64 @@ export function computed(fn) {
181
181
  effect(() => derived.set(fn()));
182
182
  return derived;
183
183
  }
184
+
185
+ /* ---------- hydration ---------- */
186
+
187
+ // A cursor over server-rendered nodes, so hydration can bind to what is
188
+ // already on the page instead of replacing it. Replacing destroys the
189
+ // reader's focus, caret and anything they had expanded.
190
+ //
191
+ // `next` returns the node the compiled module expected, or null when the
192
+ // markup does not match — a stale cache, an edited page, a host without
193
+ // a walkable DOM. The module then creates that node as it always did, so
194
+ // a mismatch costs the work we already do rather than a broken page.
195
+ export function adopt(parent) {
196
+ // A minimal DOM stub — the compiler's own tests use one — has no
197
+ // childNodes to walk. Creating is then the only option.
198
+ if (!parent || typeof parent !== 'object' || !parent.firstChild) {
199
+ return { next: () => null, done: () => {} };
200
+ }
201
+
202
+ // Snapshotted rather than walked through `nextSibling`. The caller
203
+ // appends each node it adopts, and appending a node that is already a
204
+ // child detaches and re-attaches it — which rewrites `nextSibling` for
205
+ // whatever preceded it. A lazy walk therefore ends up pointing at a
206
+ // node it already handed out, and the sweep below deletes the live
207
+ // page. Measured: every child adopted in the right order, and every
208
+ // one removed a moment later. The list is fixed before any of that can
209
+ // happen, so reordering cannot move the cursor.
210
+ const kids = [];
211
+ for (let child = parent.firstChild; child; child = child.nextSibling) kids.push(child);
212
+ let i = 0;
213
+
214
+ return {
215
+ // `expect` is a tag name for an element, null for a text node, or
216
+ // '#comment' for one of the markers a control-flow block anchors on.
217
+ // A block's markers are the nodes it later inserts and removes
218
+ // against, so adopting the region between them means adopting the
219
+ // pair itself first.
220
+ next(expect) {
221
+ const current = kids[i];
222
+ if (!current) return null;
223
+
224
+ const type = current.nodeType;
225
+ const matches =
226
+ expect === null
227
+ ? type === 3
228
+ : expect === '#comment'
229
+ ? type === 8
230
+ : type !== 3 && type !== 8 && current.nodeName?.toLowerCase() === expect;
231
+
232
+ if (!matches) return null;
233
+
234
+ i++;
235
+ return current;
236
+ },
237
+
238
+ // Anything the server sent that the module did not claim is stale
239
+ // and has to go, or it would linger below the adopted nodes.
240
+ done() {
241
+ for (; i < kids.length; i++) kids[i].remove?.();
242
+ },
243
+ };
244
+ }
@@ -102,7 +102,7 @@ function renderScope(node, scope, modules = {}) {
102
102
  // so the body sees it the same way the compiled version does.
103
103
  function renderEach(node, scope, outer, modules = {}) {
104
104
  const items = evalExpr(node.expr, scope);
105
- if (items === null || items === undefined) return '';
105
+ if (items === null || items === undefined) return wrapBlock('');
106
106
 
107
107
  if (typeof items[Symbol.iterator] !== 'function') {
108
108
  throw new BuildError(
@@ -121,12 +121,23 @@ function renderEach(node, scope, outer, modules = {}) {
121
121
  index++;
122
122
  }
123
123
 
124
- return html;
124
+ return wrapBlock(html);
125
125
  }
126
126
 
127
127
  function renderIf(node, scope, outer, modules = {}) {
128
128
  const branch = evalExpr(node.expr, scope) ? node.then : node.otherwise;
129
- return branch.map((child) => renderNode(child, scope, outer, modules)).join('');
129
+ const inner = branch.map((child) => renderNode(child, scope, outer, modules)).join('');
130
+
131
+ return wrapBlock(inner);
132
+ }
133
+
134
+ // The same start/end pair the compiler builds, so hydration can adopt
135
+ // the server's nodes instead of discarding them. Labelled, because an
136
+ // empty comment cannot be told apart from its neighbour.
137
+ //
138
+ // See docs/hydration.md.
139
+ function wrapBlock(html) {
140
+ return `<!--[-->${html}<!--]-->`;
130
141
  }
131
142
 
132
143
  // Reads an attribute expression. A bound one names a signal, so it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azoxjs",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Azox Framework — The Sound of Future Web",
5
5
  "type": "module",
6
6
  "license": "MIT",