@chr33s/solarflare 0.0.10 → 0.0.12
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/package.json +1 -1
- package/readme.md +1 -0
- package/src/diff-dom-streaming.ts +34 -2
- package/src/router-stream.ts +3 -0
- package/src/router.ts +30 -1
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -128,6 +128,7 @@ import { Deferred } from "@chr33s/solarflare/client";
|
|
|
128
128
|
<meta name="sf:base" content="/" />
|
|
129
129
|
<meta name="sf:scroll-behavior" content="auto" />
|
|
130
130
|
<meta name="sf:view-transitions" content="false" />
|
|
131
|
+
<meta name="sf:skip-node-replacement" content="s-*" />
|
|
131
132
|
|
|
132
133
|
<!-- performance -->
|
|
133
134
|
<meta name="sf:preconnect" content="https://cdn.example.com" />
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// src: https://github.com/brisa-build/diff-dom-streaming/blob/main/src/index.ts
|
|
2
2
|
|
|
3
|
+
type ShouldReplaceNode = (node: Node) => boolean;
|
|
4
|
+
|
|
3
5
|
type Walker = {
|
|
4
6
|
root: Node | null;
|
|
5
7
|
[FIRST_CHILD]: (node: Node) => Promise<Node | null>;
|
|
6
8
|
[NEXT_SIBLING]: (node: Node) => Promise<Node | null>;
|
|
7
9
|
[APPLY_TRANSITION]: (v: () => void) => void;
|
|
8
10
|
[FLUSH_SYNC]: () => void;
|
|
11
|
+
shouldReplaceNode?: ShouldReplaceNode;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
14
|
type NextNodeCallback = (node: Node) => void;
|
|
@@ -18,6 +21,8 @@ type Options = {
|
|
|
18
21
|
onChunkProcessed?: () => void;
|
|
19
22
|
/** Apply mutations synchronously instead of batching via requestAnimationFrame. */
|
|
20
23
|
syncMutations?: boolean;
|
|
24
|
+
/** Elements matching this predicate are atomically replaced instead of diffed. */
|
|
25
|
+
shouldReplaceNode?: ShouldReplaceNode;
|
|
21
26
|
};
|
|
22
27
|
|
|
23
28
|
const ELEMENT_TYPE = 1;
|
|
@@ -75,6 +80,19 @@ async function updateNode(oldNode: Node, newNode: Node, walker: Walker) {
|
|
|
75
80
|
});
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
// Elements matching shouldReplaceNode are atomically replaced.
|
|
84
|
+
// Use importNode to create the element in the target document context,
|
|
85
|
+
// avoiding adoptedCallback from firing on third-party web components.
|
|
86
|
+
if (walker.shouldReplaceNode?.(oldNode) || walker.shouldReplaceNode?.(newNode)) {
|
|
87
|
+
return walker[APPLY_TRANSITION](() => {
|
|
88
|
+
if (oldNode.parentNode) {
|
|
89
|
+
const doc = oldNode.ownerDocument ?? document;
|
|
90
|
+
const imported = doc.importNode(newNode, true);
|
|
91
|
+
oldNode.parentNode.replaceChild(imported, oldNode);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
await setChildNodes(oldNode, newNode, walker);
|
|
79
97
|
|
|
80
98
|
walker[APPLY_TRANSITION](() => {
|
|
@@ -147,6 +165,17 @@ function setAttributes(oldAttributes: NamedNodeMap, newAttributes: NamedNodeMap)
|
|
|
147
165
|
}
|
|
148
166
|
}
|
|
149
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Clone a node, using importNode for elements matching shouldReplaceNode
|
|
170
|
+
* to avoid triggering adoptedCallback on third-party web components.
|
|
171
|
+
*/
|
|
172
|
+
function cloneForDocument(node: Node, targetDoc: Document, walker: Walker): Node {
|
|
173
|
+
if (walker.shouldReplaceNode?.(node)) {
|
|
174
|
+
return targetDoc.importNode(node, true);
|
|
175
|
+
}
|
|
176
|
+
return node.cloneNode(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
150
179
|
/**
|
|
151
180
|
* Utility that will nodes childern to match another nodes children.
|
|
152
181
|
*/
|
|
@@ -237,7 +266,8 @@ async function setChildNodes(oldParent: Node, newParent: Node, walker: Walker) {
|
|
|
237
266
|
checkOld = oldNode;
|
|
238
267
|
oldNode = oldNode.nextSibling;
|
|
239
268
|
if (getKey(checkOld)) {
|
|
240
|
-
|
|
269
|
+
const targetDoc = oldParent.ownerDocument ?? document;
|
|
270
|
+
insertedNode = cloneForDocument(newNode, targetDoc, walker);
|
|
241
271
|
if (shouldInsertImmediately(insertedNode!)) {
|
|
242
272
|
flushPendingInsertions();
|
|
243
273
|
walker[APPLY_TRANSITION](() => {
|
|
@@ -258,7 +288,8 @@ async function setChildNodes(oldParent: Node, newParent: Node, walker: Walker) {
|
|
|
258
288
|
await updateNode(checkOld, newNode, walker);
|
|
259
289
|
}
|
|
260
290
|
} else {
|
|
261
|
-
|
|
291
|
+
const targetDoc = oldParent.ownerDocument ?? document;
|
|
292
|
+
insertedNode = cloneForDocument(newNode, targetDoc, walker);
|
|
262
293
|
if (shouldInsertImmediately(insertedNode!)) {
|
|
263
294
|
flushPendingInsertions();
|
|
264
295
|
walker[APPLY_TRANSITION](() => oldParent.appendChild(insertedNode!));
|
|
@@ -426,5 +457,6 @@ async function htmlStreamWalker(stream: ReadableStream, options: Options = {}) {
|
|
|
426
457
|
}
|
|
427
458
|
},
|
|
428
459
|
[FLUSH_SYNC]: flushMutationsSync,
|
|
460
|
+
shouldReplaceNode: options.shouldReplaceNode,
|
|
429
461
|
};
|
|
430
462
|
}
|
package/src/router-stream.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface ApplyPatchStreamOptions {
|
|
|
19
19
|
useTransition: boolean;
|
|
20
20
|
applyMeta: (meta: PatchMeta) => void;
|
|
21
21
|
onChunkProcessed?: () => void;
|
|
22
|
+
/** Elements matching this predicate are atomically replaced instead of diffed. */
|
|
23
|
+
shouldReplaceNode?: (node: Node) => boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export async function applyPatchStream(response: Response, options: ApplyPatchStreamOptions) {
|
|
@@ -60,6 +62,7 @@ export async function applyPatchStream(response: Response, options: ApplyPatchSt
|
|
|
60
62
|
transition: options.useTransition,
|
|
61
63
|
syncMutations: !options.useTransition,
|
|
62
64
|
onChunkProcessed: options.onChunkProcessed,
|
|
65
|
+
shouldReplaceNode: options.shouldReplaceNode,
|
|
63
66
|
});
|
|
64
67
|
await consumeHtml;
|
|
65
68
|
}
|
package/src/router.ts
CHANGED
|
@@ -35,11 +35,31 @@ export interface RouterConfig {
|
|
|
35
35
|
onNotFound?: (url: URL) => void;
|
|
36
36
|
onNavigate?: (match: RouteMatch) => void;
|
|
37
37
|
onError?: (error: Error, url: URL) => void;
|
|
38
|
+
/** Elements matching this predicate are atomically replaced instead of diffed. */
|
|
39
|
+
shouldReplaceNode?: (node: Node) => boolean;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** Subscription callback for route changes. */
|
|
41
43
|
export type RouteSubscriber = (match: RouteMatch | null) => void;
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Creates a node matcher from a glob-like pattern.
|
|
47
|
+
* Supports: "s-*" (prefix), "*-element" (suffix), "my-component" (exact)
|
|
48
|
+
* Multiple patterns can be comma-separated: "s-*,ui-*"
|
|
49
|
+
*/
|
|
50
|
+
export function createNodeMatcher(pattern: string): (node: Node) => boolean {
|
|
51
|
+
const patterns = pattern.split(",").map((p) => p.trim().toUpperCase());
|
|
52
|
+
const matchers = patterns.map((p) => {
|
|
53
|
+
if (p.endsWith("*")) return (name: string) => name.startsWith(p.slice(0, -1));
|
|
54
|
+
if (p.startsWith("*")) return (name: string) => name.endsWith(p.slice(1));
|
|
55
|
+
return (name: string) => name === p;
|
|
56
|
+
});
|
|
57
|
+
return (node: Node) => {
|
|
58
|
+
const name = node.nodeName;
|
|
59
|
+
return matchers.some((m) => m(name));
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** Checks if View Transitions API is supported. */
|
|
44
64
|
export function supportsViewTransitions() {
|
|
45
65
|
return typeof document !== "undefined" && "startViewTransition" in document;
|
|
@@ -48,7 +68,8 @@ export function supportsViewTransitions() {
|
|
|
48
68
|
/** Client-side SPA router using Navigation API and View Transitions. */
|
|
49
69
|
export class Router {
|
|
50
70
|
#routes: Route[] = [];
|
|
51
|
-
#config: Required<RouterConfig
|
|
71
|
+
#config: Required<Omit<RouterConfig, "shouldReplaceNode">> &
|
|
72
|
+
Pick<RouterConfig, "shouldReplaceNode">;
|
|
52
73
|
#started = false;
|
|
53
74
|
#cleanupFns: (() => void)[] = [];
|
|
54
75
|
#inflightAbort: AbortController | null = null;
|
|
@@ -72,6 +93,12 @@ export class Router {
|
|
|
72
93
|
const metaBase = getMeta("base");
|
|
73
94
|
const metaViewTransitions = getMeta<"true" | "false">("view-transitions");
|
|
74
95
|
const metaScrollBehavior = getMeta<"auto" | "smooth" | "instant">("scroll-behavior");
|
|
96
|
+
const metaSkipNodeReplace = getMeta("skip-node-replacement");
|
|
97
|
+
|
|
98
|
+
// Convert glob pattern (e.g., "s-*") to shouldReplaceNode predicate
|
|
99
|
+
const shouldReplaceNode =
|
|
100
|
+
config.shouldReplaceNode ??
|
|
101
|
+
(metaSkipNodeReplace ? createNodeMatcher(metaSkipNodeReplace) : undefined);
|
|
75
102
|
|
|
76
103
|
this.#config = {
|
|
77
104
|
base: config.base ?? metaBase ?? manifest.base ?? "",
|
|
@@ -84,6 +111,7 @@ export class Router {
|
|
|
84
111
|
((error, url) => {
|
|
85
112
|
console.error(`[solarflare] Navigation error for ${url.href}:`, error);
|
|
86
113
|
}),
|
|
114
|
+
shouldReplaceNode,
|
|
87
115
|
};
|
|
88
116
|
|
|
89
117
|
this.params = computed(() => this.current.value?.params ?? {});
|
|
@@ -293,6 +321,7 @@ export class Router {
|
|
|
293
321
|
await applyPatchStream(response, {
|
|
294
322
|
useTransition,
|
|
295
323
|
applyMeta,
|
|
324
|
+
shouldReplaceNode: this.#config.shouldReplaceNode,
|
|
296
325
|
onChunkProcessed: () => {
|
|
297
326
|
if (didScroll) return;
|
|
298
327
|
didScroll = true;
|