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 +1 -1
- package/core/compiler/compileToJs.js +208 -31
- package/core/index.d.ts +2 -0
- package/core/index.js +1 -0
- package/core/reactivity/signal.d.ts +18 -0
- package/core/reactivity/signal.js +61 -0
- package/core/renderer/renderToHtml.js +14 -3
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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(` //
|
|
632
|
-
statements.push(` //
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/core/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|