@tschk/moonshine-solid 0.4.1 → 0.4.4
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 +259 -23
- package/package.json +22 -8
- package/src/children.ts +30 -0
- package/src/cleanup-finalization.ts +91 -0
- package/src/cleanup-policy.ts +39 -0
- package/src/component-factory.ts +62 -0
- package/src/context.ts +53 -0
- package/src/control.ts +6 -0
- package/src/dom.ts +3 -0
- package/src/dynamic.ts +90 -0
- package/src/events.ts +185 -0
- package/src/framework-native.ts +1 -0
- package/src/framework.ts +32 -115
- package/src/h.ts +2 -4
- package/src/index.ts +20 -246
- package/src/ir.ts +213 -0
- package/src/lazy.ts +62 -0
- package/src/portal-h.ts +42 -0
- package/src/portal.ts +56 -0
- package/src/props.ts +152 -0
- package/src/reactive.ts +1 -0
- package/src/solid.ts +198 -16
- package/src/ssr.ts +41 -0
- package/src/store.ts +7 -4
package/src/dynamic.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { h } from "./component-factory";
|
|
2
|
+
/** Native dynamic components; not Solid's compiled web instruction ABI. */
|
|
3
|
+
import { eventRegistration, type DOMChild } from "@tschk/moonshine-dom/dom";
|
|
4
|
+
import {
|
|
5
|
+
createMemo,
|
|
6
|
+
getOwner,
|
|
7
|
+
createRenderEffect,
|
|
8
|
+
onCleanup,
|
|
9
|
+
untrack,
|
|
10
|
+
type Accessor,
|
|
11
|
+
} from "@tschk/moonshine-dom/reactive";
|
|
12
|
+
import { isSSRRendering } from "@tschk/moonshine-dom/ssr-owner";
|
|
13
|
+
import { peekComponentContext } from "@tschk/moonshine-dom/component";
|
|
14
|
+
import { splitProps } from "./props";
|
|
15
|
+
import { registerSolidEvent } from "./events";
|
|
16
|
+
|
|
17
|
+
export type DynamicComponent<R = DOMChild> = string | ((props: any) => R);
|
|
18
|
+
export type DynamicProps<T> = T extends (props: infer P) => unknown
|
|
19
|
+
? P
|
|
20
|
+
: Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
function intrinsicProps(props: object): Record<string, unknown> {
|
|
23
|
+
return new Proxy(
|
|
24
|
+
{},
|
|
25
|
+
{
|
|
26
|
+
ownKeys: () => Reflect.ownKeys(props),
|
|
27
|
+
has: (_, key) => Reflect.has(props, key),
|
|
28
|
+
getOwnPropertyDescriptor: (_, key) =>
|
|
29
|
+
Reflect.has(props, key)
|
|
30
|
+
? {
|
|
31
|
+
configurable: true,
|
|
32
|
+
enumerable:
|
|
33
|
+
Reflect.getOwnPropertyDescriptor(props, key)?.enumerable ??
|
|
34
|
+
true,
|
|
35
|
+
}
|
|
36
|
+
: undefined,
|
|
37
|
+
get: (_, key) => {
|
|
38
|
+
if (key === eventRegistration)
|
|
39
|
+
return (element: Element, name: string, read: () => unknown) => {
|
|
40
|
+
const listener = createMemo(read);
|
|
41
|
+
let dispose: ReturnType<typeof registerSolidEvent> | undefined;
|
|
42
|
+
onCleanup(() => dispose?.());
|
|
43
|
+
createRenderEffect(() => {
|
|
44
|
+
const value = listener();
|
|
45
|
+
if (dispose?.update?.(value)) return;
|
|
46
|
+
const previous = dispose;
|
|
47
|
+
dispose = undefined;
|
|
48
|
+
previous?.();
|
|
49
|
+
dispose = registerSolidEvent(element, name, value);
|
|
50
|
+
});
|
|
51
|
+
return () => {};
|
|
52
|
+
};
|
|
53
|
+
if (key === "ref" || key === "xmlns")
|
|
54
|
+
return Reflect.get(props, key, props);
|
|
55
|
+
return () => Reflect.get(props, key, props);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createDynamic<T extends DynamicComponent>(
|
|
62
|
+
component: () => T | undefined,
|
|
63
|
+
props: DynamicProps<NoInfer<T>>,
|
|
64
|
+
): Accessor<DOMChild | undefined> {
|
|
65
|
+
if (isSSRRendering() || peekComponentContext()?.mode === "ssr")
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Server dynamic components require the explicit /ssr entry",
|
|
68
|
+
);
|
|
69
|
+
const owner = getOwner();
|
|
70
|
+
if (!owner || owner.disposed)
|
|
71
|
+
throw new Error(
|
|
72
|
+
"Dynamic component invocation requires a live native owner",
|
|
73
|
+
);
|
|
74
|
+
const selected = createMemo(component);
|
|
75
|
+
return createMemo(() => {
|
|
76
|
+
const value = selected();
|
|
77
|
+
if (typeof value === "function")
|
|
78
|
+
return untrack(() => h(value, props as Record<string, unknown>));
|
|
79
|
+
if (typeof value === "string")
|
|
80
|
+
return untrack(() => h(value, intrinsicProps(props as object)));
|
|
81
|
+
return undefined;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Dynamic<T extends DynamicComponent>(
|
|
86
|
+
props: { component: T | undefined } & DynamicProps<NoInfer<T>>,
|
|
87
|
+
): Accessor<DOMChild | undefined> {
|
|
88
|
+
const [, rest] = splitProps(props, ["component"]);
|
|
89
|
+
return createDynamic(() => props.component, rest as DynamicProps<T>);
|
|
90
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Adapter-scoped events, independent of Solid's runtime. */
|
|
2
|
+
type Handler =
|
|
3
|
+
EventListenerOrEventListenerObject | readonly [Function, unknown];
|
|
4
|
+
const delegated = new Set([
|
|
5
|
+
"beforeinput",
|
|
6
|
+
"click",
|
|
7
|
+
"dblclick",
|
|
8
|
+
"contextmenu",
|
|
9
|
+
"focusin",
|
|
10
|
+
"focusout",
|
|
11
|
+
"input",
|
|
12
|
+
"keydown",
|
|
13
|
+
"keyup",
|
|
14
|
+
"mousedown",
|
|
15
|
+
"mousemove",
|
|
16
|
+
"mouseout",
|
|
17
|
+
"mouseover",
|
|
18
|
+
"mouseup",
|
|
19
|
+
"pointerdown",
|
|
20
|
+
"pointermove",
|
|
21
|
+
"pointerout",
|
|
22
|
+
"pointerover",
|
|
23
|
+
"pointerup",
|
|
24
|
+
"touchend",
|
|
25
|
+
"touchmove",
|
|
26
|
+
"touchstart",
|
|
27
|
+
]);
|
|
28
|
+
const handlers = new WeakMap<Node, Map<string, Handler>>();
|
|
29
|
+
const documents = new WeakMap<
|
|
30
|
+
Document,
|
|
31
|
+
Map<string, { count: number; listener: EventListener }>
|
|
32
|
+
>();
|
|
33
|
+
export const portalHosts = new WeakMap<Node, () => Node | null>();
|
|
34
|
+
function invoke(
|
|
35
|
+
handler: Handler,
|
|
36
|
+
node: Node,
|
|
37
|
+
event: Event,
|
|
38
|
+
isDelegated = false,
|
|
39
|
+
) {
|
|
40
|
+
if (Array.isArray(handler))
|
|
41
|
+
Reflect.apply(
|
|
42
|
+
handler[0],
|
|
43
|
+
node,
|
|
44
|
+
isDelegated && handler[1] === undefined ? [event] : [handler[1], event],
|
|
45
|
+
);
|
|
46
|
+
else if (typeof handler === "function") Reflect.apply(handler, node, [event]);
|
|
47
|
+
else (handler as EventListenerObject).handleEvent(event);
|
|
48
|
+
}
|
|
49
|
+
function dispatch(event: Event, document: Document) {
|
|
50
|
+
const path = event.composedPath();
|
|
51
|
+
let node: Node | null =
|
|
52
|
+
path[0] instanceof Node ? path[0] : (event.target as Node);
|
|
53
|
+
const originalTarget = event.target;
|
|
54
|
+
Object.defineProperty(event, "currentTarget", {
|
|
55
|
+
configurable: true,
|
|
56
|
+
get: () => node ?? document,
|
|
57
|
+
});
|
|
58
|
+
if (node !== originalTarget)
|
|
59
|
+
Object.defineProperty(event, "target", { configurable: true, value: node });
|
|
60
|
+
try {
|
|
61
|
+
while (node) {
|
|
62
|
+
const handler = handlers.get(node)?.get(event.type);
|
|
63
|
+
if (handler && !(node as HTMLButtonElement).disabled)
|
|
64
|
+
invoke(handler, node, event, true);
|
|
65
|
+
if (event.cancelBubble) break;
|
|
66
|
+
// Ordinary shadow boundaries retarget; a portal keeps its logical target.
|
|
67
|
+
if (
|
|
68
|
+
node instanceof ShadowRoot &&
|
|
69
|
+
!portalHosts.has(node.host) &&
|
|
70
|
+
node.contains(event.target as Node)
|
|
71
|
+
)
|
|
72
|
+
Object.defineProperty(event, "target", {
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: node.host,
|
|
75
|
+
});
|
|
76
|
+
node = portalHosts.has(node)
|
|
77
|
+
? portalHosts.get(node)!()
|
|
78
|
+
: (node.parentNode ?? (node as ShadowRoot).host ?? null);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
Object.defineProperty(event, "target", {
|
|
82
|
+
configurable: true,
|
|
83
|
+
value: originalTarget,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
type Registration = (() => void) & { update?: (value: unknown) => boolean };
|
|
88
|
+
export function registerSolidEvent(
|
|
89
|
+
element: Element,
|
|
90
|
+
name: string,
|
|
91
|
+
value: unknown,
|
|
92
|
+
): Registration {
|
|
93
|
+
if (value == null) return () => {};
|
|
94
|
+
const capture = name.startsWith("oncapture:");
|
|
95
|
+
const direct = name.startsWith("on:");
|
|
96
|
+
const type = capture
|
|
97
|
+
? name.slice(10)
|
|
98
|
+
: direct
|
|
99
|
+
? name.slice(3)
|
|
100
|
+
: name.slice(2).toLowerCase();
|
|
101
|
+
if (
|
|
102
|
+
typeof value !== "function" &&
|
|
103
|
+
!(
|
|
104
|
+
value &&
|
|
105
|
+
typeof value === "object" &&
|
|
106
|
+
typeof (value as EventListenerObject).handleEvent === "function"
|
|
107
|
+
) &&
|
|
108
|
+
!(Array.isArray(value) && typeof value[0] === "function")
|
|
109
|
+
)
|
|
110
|
+
throw TypeError("Invalid Solid event handler");
|
|
111
|
+
const object = typeof value === "object" && !Array.isArray(value);
|
|
112
|
+
if (direct || capture || object || !delegated.has(type)) {
|
|
113
|
+
const handler = Array.isArray(value)
|
|
114
|
+
? (event: Event) => invoke(value as unknown as Handler, element, event)
|
|
115
|
+
: (value as EventListenerOrEventListenerObject);
|
|
116
|
+
const input = object ? (value as AddEventListenerOptions) : undefined;
|
|
117
|
+
const options = capture
|
|
118
|
+
? true
|
|
119
|
+
: input
|
|
120
|
+
? {
|
|
121
|
+
capture: !!input.capture,
|
|
122
|
+
once: !!input.once,
|
|
123
|
+
passive: !!input.passive,
|
|
124
|
+
signal: input.signal,
|
|
125
|
+
}
|
|
126
|
+
: undefined;
|
|
127
|
+
const registeredCapture =
|
|
128
|
+
typeof options === "boolean" ? options : !!options?.capture;
|
|
129
|
+
element.addEventListener(type, handler, options);
|
|
130
|
+
return () => element.removeEventListener(type, handler, registeredCapture);
|
|
131
|
+
}
|
|
132
|
+
let own = handlers.get(element);
|
|
133
|
+
if (!own) handlers.set(element, (own = new Map()));
|
|
134
|
+
own.set(type, value as unknown as Handler);
|
|
135
|
+
const release = acquireDocument(element.ownerDocument, type);
|
|
136
|
+
let active = true;
|
|
137
|
+
const dispose: Registration = () => {
|
|
138
|
+
if (!active) return;
|
|
139
|
+
active = false;
|
|
140
|
+
own!.delete(type);
|
|
141
|
+
release();
|
|
142
|
+
};
|
|
143
|
+
dispose.update = (next) => {
|
|
144
|
+
if (
|
|
145
|
+
!active ||
|
|
146
|
+
!(
|
|
147
|
+
typeof next === "function" ||
|
|
148
|
+
(Array.isArray(next) && typeof next[0] === "function")
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return false;
|
|
152
|
+
own!.set(type, next as Handler);
|
|
153
|
+
return true;
|
|
154
|
+
};
|
|
155
|
+
return dispose;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function acquireDocument(document: Document, type: string): () => void {
|
|
159
|
+
let counts = documents.get(document);
|
|
160
|
+
if (!counts) documents.set(document, (counts = new Map()));
|
|
161
|
+
let record = counts.get(type);
|
|
162
|
+
if (!record) {
|
|
163
|
+
record = { count: 0, listener: (event) => dispatch(event, document) };
|
|
164
|
+
counts.set(type, record);
|
|
165
|
+
document.addEventListener(type, record.listener);
|
|
166
|
+
}
|
|
167
|
+
record.count++;
|
|
168
|
+
let active = true;
|
|
169
|
+
return () => {
|
|
170
|
+
if (!active) return;
|
|
171
|
+
active = false;
|
|
172
|
+
if (!--record!.count) {
|
|
173
|
+
document.removeEventListener(type, record!.listener);
|
|
174
|
+
counts!.delete(type);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export function acquirePortalDocument(document: Document): () => void {
|
|
179
|
+
const releases = Array.from(delegated, (type) =>
|
|
180
|
+
acquireDocument(document, type),
|
|
181
|
+
);
|
|
182
|
+
return () => {
|
|
183
|
+
for (const release of releases) release();
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@tschk/moonshine-dom/framework-native";
|
package/src/framework.ts
CHANGED
|
@@ -1,124 +1,41 @@
|
|
|
1
1
|
import type { RenderContext, Renderer } from "@tschk/moonshine-framework";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
async function loadComponent(
|
|
21
|
-
file: string,
|
|
22
|
-
): Promise<(props: unknown) => SsrNode> {
|
|
23
|
-
const mod = await import(file);
|
|
24
|
-
return (mod.default ?? mod) as (props: unknown) => SsrNode;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function escapeAttr(value: string): string {
|
|
28
|
-
return (escape as (s: string, attr: boolean) => string)(value, true);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function Shell(props: { mode: string; children: SsrNode }): SsrNode {
|
|
32
|
-
return ssr(
|
|
33
|
-
["<!DOCTYPE html><html><head></head>", "</html>"],
|
|
34
|
-
ssr(
|
|
35
|
-
['<body data-moonshine-mode="', '">', "</body>"],
|
|
36
|
-
escapeAttr(props.mode),
|
|
37
|
-
props.children,
|
|
38
|
-
),
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function buildBody(context: RenderContext): Promise<SsrNode> {
|
|
43
|
-
const Page = await loadComponent(context.route.file);
|
|
44
|
-
const pageProps: PageProps = { params: context.params, data: context.data };
|
|
45
|
-
let body = Page(pageProps);
|
|
46
|
-
if (context.route.layouts) {
|
|
47
|
-
for (let i = context.route.layouts.length - 1; i >= 0; i--) {
|
|
48
|
-
const Layout = await loadComponent(context.route.layouts[i]!);
|
|
49
|
-
body = Layout({ ...pageProps, children: body });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return Shell({ mode: context.route.mode, children: body });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function buildSpaShell(context: RenderContext): SsrNode {
|
|
56
|
-
const clientTags = context.route.clientEntries
|
|
57
|
-
.map((href) => `<script type="module" src="${escapeAttr(href)}"></script>`)
|
|
58
|
-
.join("");
|
|
59
|
-
const body = ssr(
|
|
60
|
-
[
|
|
61
|
-
'<body data-moonshine-mode="',
|
|
62
|
-
'"><div id="moonshine-app" data-moonshine-app="',
|
|
63
|
-
'"></div>',
|
|
64
|
-
"</body>",
|
|
65
|
-
],
|
|
66
|
-
escapeAttr(context.route.mode),
|
|
67
|
-
escapeAttr(context.route.id),
|
|
68
|
-
clientTags,
|
|
69
|
-
);
|
|
70
|
-
return ssr(
|
|
71
|
-
["<!DOCTYPE html><html><head>", "</head>", "</html>"],
|
|
72
|
-
clientTags,
|
|
73
|
-
body,
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function renderPageToString(context: RenderContext): Promise<string> {
|
|
78
|
-
const body = await buildBody(context);
|
|
79
|
-
return renderToStringAsync(() => body);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function renderPage(context: RenderContext): Promise<Response> {
|
|
83
|
-
const body = await buildBody(context);
|
|
84
|
-
const { readable, writable } = new TransformStream();
|
|
85
|
-
const stream = renderToStream(() => body);
|
|
86
|
-
stream.pipeTo(writable);
|
|
87
|
-
return new Response(readable, {
|
|
88
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
2
|
+
import { escapeHtml } from "@tschk/moonshine-dom/ssr";
|
|
3
|
+
import type { DOMChild } from "@tschk/moonshine-dom/dom";
|
|
4
|
+
|
|
5
|
+
async function prerender(context: RenderContext): Promise<string> {
|
|
6
|
+
const { sourceRenderer } =
|
|
7
|
+
await import("@tschk/moonshine-dom/framework-source");
|
|
8
|
+
const html = await sourceRenderer.prerender(context);
|
|
9
|
+
return context.route.mode === "api"
|
|
10
|
+
? html
|
|
11
|
+
: html.replace(
|
|
12
|
+
"<body>",
|
|
13
|
+
`<body data-moonshine-mode="${escapeHtml(context.route.mode)}">`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Compatibility name for the independent, buffered server renderer. */
|
|
92
18
|
export const solidRenderer: Renderer = {
|
|
93
19
|
name: "solid",
|
|
94
|
-
|
|
95
|
-
if (context.route.mode === "api") return JSON.stringify(context.data);
|
|
96
|
-
if (context.route.mode === "spa") {
|
|
97
|
-
return renderToStringAsync(() => buildSpaShell(context));
|
|
98
|
-
}
|
|
99
|
-
return renderPageToString(context);
|
|
100
|
-
},
|
|
20
|
+
prerender,
|
|
101
21
|
async render(context) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
return renderPage(context);
|
|
22
|
+
return new Response(await prerender(context), {
|
|
23
|
+
headers: {
|
|
24
|
+
"content-type":
|
|
25
|
+
context.route.mode === "api"
|
|
26
|
+
? "application/json; charset=utf-8"
|
|
27
|
+
: "text/html; charset=utf-8",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
114
30
|
},
|
|
115
31
|
};
|
|
116
32
|
|
|
117
33
|
export function hydrateSolid<T>(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
):
|
|
122
|
-
|
|
123
|
-
|
|
34
|
+
_Component: (props: T) => DOMChild,
|
|
35
|
+
_props: T,
|
|
36
|
+
_container: Element,
|
|
37
|
+
): never {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Solid hydration is not implemented; compile with Crepus and use native render for a fresh mount",
|
|
40
|
+
);
|
|
124
41
|
}
|
package/src/h.ts
CHANGED