@kasperrt/smolter 0.4.1 → 0.4.2
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/.generated/client/deployment.ts +40 -25
- package/.generated/files/index.ts +5 -3
- package/.generated/preact/await-fallback.tsx +33 -0
- package/.generated/preact/await.tsx +22 -5
- package/.generated/preact/client.tsx +8 -3
- package/.generated/preact/deferred-hydration.ts +14 -1
- package/.generated/preact/root-context.ts +4 -0
- package/.generated/server/application.tsx +10 -0
- package/.generated/vite/application/lazy-files.ts +10 -17
- package/.generated/vite/index.ts +14 -0
- package/CHANGELOG.md +8 -0
- package/README.md +29 -9
- package/dist/client/deployment.js +30 -22
- package/dist/files/index.js +4 -3
- package/dist/preact/await-fallback.d.ts +4 -0
- package/dist/preact/await-fallback.js +29 -0
- package/dist/preact/await.d.ts +4 -1
- package/dist/preact/await.js +21 -3
- package/dist/preact/client.d.ts +2 -0
- package/dist/preact/client.js +2 -2
- package/dist/preact/deferred-hydration.d.ts +1 -1
- package/dist/preact/deferred-hydration.js +7 -1
- package/dist/preact/document.js +1 -1
- package/dist/preact/root-context.d.ts +1 -0
- package/dist/preact/route-hooks.js +1 -1
- package/dist/preact/router.js +1 -1
- package/dist/server/application.js +8 -0
- package/dist/vite/application/lazy-files.js +5 -11
- package/dist/vite/index.js +6 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { NavigationRouter, Router } from "../core/index.ts";
|
|
2
2
|
import { deploymentCheck } from "./deployment-check.ts";
|
|
3
3
|
|
|
4
|
+
interface WatcherState {
|
|
5
|
+
sequence: number;
|
|
6
|
+
mutations: number;
|
|
7
|
+
cleanup?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const watchers = new WeakMap<NavigationRouter, WatcherState>();
|
|
11
|
+
|
|
4
12
|
/** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
|
|
5
13
|
export interface DeploymentOptions {
|
|
6
14
|
version: string;
|
|
@@ -18,47 +26,50 @@ export function watchDeployment(
|
|
|
18
26
|
): () => void {
|
|
19
27
|
if (!options || typeof window === "undefined") return () => {};
|
|
20
28
|
|
|
29
|
+
const state = watchers.get(router) ?? { sequence: 0, mutations: 0 };
|
|
30
|
+
state.cleanup?.();
|
|
31
|
+
watchers.set(router, state);
|
|
21
32
|
const check = deploymentCheck(options);
|
|
22
33
|
const { changed, reload } = check;
|
|
23
34
|
const read = router.getSnapshot;
|
|
24
35
|
const navigate = router.navigate;
|
|
25
36
|
const dispose = router.dispose;
|
|
26
|
-
let sequence = 0;
|
|
27
37
|
let stopped = false;
|
|
28
38
|
let replay = false;
|
|
29
|
-
let mutations = 0;
|
|
30
39
|
const dataRouter = router as NavigationRouter &
|
|
31
|
-
Partial<Pick<Router, "createFetcher">>;
|
|
40
|
+
Partial<Pick<Router, "createFetcher" | "submit">>;
|
|
32
41
|
const createFetcher = dataRouter.createFetcher;
|
|
42
|
+
const submit = dataRouter.submit;
|
|
43
|
+
const trackSubmission =
|
|
44
|
+
(submit: Router["submit"]): Router["submit"] =>
|
|
45
|
+
(...args) => {
|
|
46
|
+
state.sequence++;
|
|
47
|
+
state.mutations++;
|
|
48
|
+
|
|
49
|
+
return submit(...args).finally(() => {
|
|
50
|
+
state.mutations--;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const guardedSubmit = submit && trackSubmission(submit);
|
|
33
54
|
const guardedFetcher =
|
|
34
55
|
createFetcher &&
|
|
35
56
|
(() => {
|
|
36
57
|
const fetcher = createFetcher();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
fetcher.submit = (...args) => {
|
|
40
|
-
sequence++;
|
|
41
|
-
mutations++;
|
|
42
|
-
|
|
43
|
-
return submit(...args).finally(() => {
|
|
44
|
-
mutations--;
|
|
45
|
-
});
|
|
46
|
-
};
|
|
58
|
+
fetcher.submit = trackSubmission(fetcher.submit);
|
|
47
59
|
|
|
48
60
|
return fetcher;
|
|
49
61
|
});
|
|
50
62
|
|
|
63
|
+
if (guardedSubmit) dataRouter.submit = guardedSubmit;
|
|
51
64
|
if (guardedFetcher) dataRouter.createFetcher = guardedFetcher;
|
|
52
|
-
const valid = (current: number) => current === sequence && !stopped;
|
|
53
|
-
const canReload = (current: number) =>
|
|
54
|
-
|
|
55
|
-
if (read().state === "submitting") sequence++;
|
|
56
|
-
});
|
|
65
|
+
const valid = (current: number) => current === state.sequence && !stopped;
|
|
66
|
+
const canReload = (current: number) =>
|
|
67
|
+
valid(current) && state.mutations === 0;
|
|
57
68
|
|
|
58
69
|
const guarded: NavigationRouter["navigate"] = async (href, operation) => {
|
|
59
70
|
const target = new URL(href, router.getSnapshot().url);
|
|
60
71
|
const currentURL = new URL(router.getSnapshot().url);
|
|
61
|
-
const current = ++sequence;
|
|
72
|
+
const current = ++state.sequence;
|
|
62
73
|
|
|
63
74
|
if (
|
|
64
75
|
target.origin === location.origin &&
|
|
@@ -70,7 +81,7 @@ export function watchDeployment(
|
|
|
70
81
|
return;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
if (current === sequence && !stopped) await navigate(href, operation);
|
|
84
|
+
if (current === state.sequence && !stopped) await navigate(href, operation);
|
|
74
85
|
};
|
|
75
86
|
|
|
76
87
|
const pop = (event: PopStateEvent) => {
|
|
@@ -78,7 +89,7 @@ export function watchDeployment(
|
|
|
78
89
|
|
|
79
90
|
const target = new URL(location.href);
|
|
80
91
|
const previous = new URL(read().url);
|
|
81
|
-
const current = ++sequence;
|
|
92
|
+
const current = ++state.sequence;
|
|
82
93
|
|
|
83
94
|
if (
|
|
84
95
|
target.pathname === previous.pathname &&
|
|
@@ -105,7 +116,7 @@ export function watchDeployment(
|
|
|
105
116
|
// consume the recovery marker before a click records its destination.
|
|
106
117
|
if (read().state !== "loading") return;
|
|
107
118
|
|
|
108
|
-
const current = sequence;
|
|
119
|
+
const current = state.sequence;
|
|
109
120
|
const target = new URL(read().url);
|
|
110
121
|
void changed().then((updated) => {
|
|
111
122
|
if (updated)
|
|
@@ -120,14 +131,17 @@ export function watchDeployment(
|
|
|
120
131
|
};
|
|
121
132
|
|
|
122
133
|
function cleanup() {
|
|
134
|
+
if (stopped) return;
|
|
135
|
+
if (state.cleanup === cleanup) state.cleanup = undefined;
|
|
123
136
|
stopped = true;
|
|
124
|
-
sequence++;
|
|
137
|
+
state.sequence++;
|
|
125
138
|
check.dispose();
|
|
126
|
-
unsubscribe();
|
|
127
139
|
window.removeEventListener("popstate", pop, true);
|
|
128
140
|
window.removeEventListener("vite:preloadError", preloadError);
|
|
129
141
|
|
|
130
|
-
if (dataRouter.
|
|
142
|
+
if (guardedSubmit && dataRouter.submit === guardedSubmit)
|
|
143
|
+
dataRouter.submit = submit;
|
|
144
|
+
if (guardedFetcher && dataRouter.createFetcher === guardedFetcher)
|
|
131
145
|
dataRouter.createFetcher = createFetcher;
|
|
132
146
|
if (router.navigate === guarded) router.navigate = navigate;
|
|
133
147
|
if (router.dispose === guardedDispose) router.dispose = dispose;
|
|
@@ -138,6 +152,7 @@ export function watchDeployment(
|
|
|
138
152
|
dispose();
|
|
139
153
|
}
|
|
140
154
|
|
|
155
|
+
state.cleanup = cleanup;
|
|
141
156
|
router.navigate = guarded;
|
|
142
157
|
router.dispose = guardedDispose;
|
|
143
158
|
window.addEventListener("popstate", pop, true);
|
|
@@ -47,10 +47,12 @@ export async function flatRoutes(
|
|
|
47
47
|
name = name.replace(flatFile, "");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
const escaped = name.replaceAll("[.]", "\0");
|
|
51
|
+
|
|
52
|
+
if (/[()[\]:*?]/.test(escaped))
|
|
51
53
|
return new Error(`Unsupported flat route name: ${name}`);
|
|
52
54
|
|
|
53
|
-
const segments =
|
|
55
|
+
const segments = escaped.split(".");
|
|
54
56
|
const parts: string[] = [];
|
|
55
57
|
|
|
56
58
|
for (const [index, segment] of segments.entries()) {
|
|
@@ -64,7 +66,7 @@ export async function flatRoutes(
|
|
|
64
66
|
|
|
65
67
|
if (segment.startsWith("_")) continue;
|
|
66
68
|
|
|
67
|
-
const part = segment.replace(/_$/, "");
|
|
69
|
+
const part = segment.replace(/_$/, "").replaceAll("\0", ".");
|
|
68
70
|
parts.push(part === "$" ? "*" : part.replace(/^\$/, ":"));
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import { useEffect, useState } from "preact/hooks";
|
|
3
|
+
|
|
4
|
+
import type { AwaitProps } from "./await.tsx";
|
|
5
|
+
|
|
6
|
+
type Result<T> = { source: Promise<T> } & ({ value: T } | { error: unknown });
|
|
7
|
+
|
|
8
|
+
export function AwaitFallback<T>({
|
|
9
|
+
resolve,
|
|
10
|
+
fallback,
|
|
11
|
+
errorElement,
|
|
12
|
+
children,
|
|
13
|
+
}: AwaitProps<T> & { resolve: Promise<T> }) {
|
|
14
|
+
const [result, setResult] = useState<Result<T>>();
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let current = true;
|
|
17
|
+
void resolve.then(
|
|
18
|
+
(value) => {
|
|
19
|
+
if (current) setResult({ source: resolve, value });
|
|
20
|
+
},
|
|
21
|
+
(error: unknown) => {
|
|
22
|
+
if (current) setResult({ source: resolve, error });
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
return () => {
|
|
26
|
+
current = false;
|
|
27
|
+
};
|
|
28
|
+
}, [resolve]);
|
|
29
|
+
if (!result || result.source !== resolve) return fallback;
|
|
30
|
+
if ("value" in result) return children(result.value as Awaited<T>);
|
|
31
|
+
if (errorElement !== undefined) return errorElement;
|
|
32
|
+
throw result.error;
|
|
33
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
1
2
|
import type { ComponentChildren } from "preact";
|
|
2
|
-
import { use } from "preact/compat";
|
|
3
|
+
import { use, useContext } from "preact/compat";
|
|
4
|
+
import { AwaitFallback } from "./await-fallback.tsx";
|
|
5
|
+
import { Application } from "./root-context.ts";
|
|
3
6
|
|
|
4
7
|
export interface AwaitProps<T> {
|
|
5
8
|
resolve: T | Promise<T>;
|
|
6
9
|
children: (value: Awaited<T>) => ComponentChildren;
|
|
7
10
|
errorElement?: ComponentChildren;
|
|
11
|
+
/** Component-owned pending content, safe during early hydration. Pass null to render nothing. */
|
|
12
|
+
fallback?: ComponentChildren;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
type Resolution = { value: unknown } | { error: unknown };
|
|
@@ -12,13 +17,25 @@ type Resolution = { value: unknown } | { error: unknown };
|
|
|
12
17
|
// Cache only the error-as-data adapter. Preact owns pending and resolved state.
|
|
13
18
|
const resolutions = new WeakMap<Promise<unknown>, Promise<Resolution>>();
|
|
14
19
|
|
|
15
|
-
export function Await<T>({
|
|
20
|
+
export function Await<T>(props: AwaitProps<T>): ComponentChildren {
|
|
21
|
+
const app = useContext(Application);
|
|
22
|
+
if (!(props.resolve instanceof Promise))
|
|
23
|
+
return props.children(props.resolve as Awaited<T>);
|
|
24
|
+
if (!("fallback" in props))
|
|
25
|
+
return <SuspendedAwait {...props} resolve={props.resolve} />;
|
|
26
|
+
if (app?.deferredBoundary)
|
|
27
|
+
return app.deferredBoundary(
|
|
28
|
+
<SuspendedAwait {...props} resolve={props.resolve} />,
|
|
29
|
+
props.fallback,
|
|
30
|
+
);
|
|
31
|
+
return <AwaitFallback {...props} resolve={props.resolve} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SuspendedAwait<T>({
|
|
16
35
|
resolve,
|
|
17
36
|
children,
|
|
18
37
|
errorElement,
|
|
19
|
-
}: AwaitProps<T>): ComponentChildren {
|
|
20
|
-
if (!(resolve instanceof Promise)) return children(resolve as Awaited<T>);
|
|
21
|
-
|
|
38
|
+
}: AwaitProps<T> & { resolve: Promise<T> }): ComponentChildren {
|
|
22
39
|
let pending = resolutions.get(resolve);
|
|
23
40
|
|
|
24
41
|
if (!pending) {
|
|
@@ -22,6 +22,8 @@ export { configureApplication } from "../core/environment.ts";
|
|
|
22
22
|
export interface MountApplicationOptions {
|
|
23
23
|
hash?: boolean;
|
|
24
24
|
deployment?: DeploymentOptions | null;
|
|
25
|
+
/** Total initial deferred-data budget in integer milliseconds; not an inactivity timeout. Defaults to 30,000. */
|
|
26
|
+
deferredTimeout?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export async function mountApplication(
|
|
@@ -29,8 +31,11 @@ export async function mountApplication(
|
|
|
29
31
|
rootModule?: RootModule,
|
|
30
32
|
options: boolean | MountApplicationOptions = false,
|
|
31
33
|
): Promise<Router> {
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
+
const {
|
|
35
|
+
hash = false,
|
|
36
|
+
deployment = null,
|
|
37
|
+
deferredTimeout,
|
|
38
|
+
} = typeof options === "boolean" ? { hash: options } : options;
|
|
34
39
|
const initialURL = new URL(
|
|
35
40
|
hash ? location.hash.slice(1) || "/" : location.href,
|
|
36
41
|
location.origin,
|
|
@@ -48,7 +53,7 @@ export async function mountApplication(
|
|
|
48
53
|
routes = prepareRoutes(routes);
|
|
49
54
|
const element = document.getElementById("smolter-data");
|
|
50
55
|
const initial = element?.textContent
|
|
51
|
-
? readHydration(element.textContent)
|
|
56
|
+
? readHydration(element.textContent, deferredTimeout)
|
|
52
57
|
: undefined;
|
|
53
58
|
const hydration: Hydration | undefined = initial?.hydration;
|
|
54
59
|
|
|
@@ -11,7 +11,12 @@ export interface DeferredWindow extends Window {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
14
|
-
export function readHydration(json: string) {
|
|
14
|
+
export function readHydration(json: string, timeout = 30_000) {
|
|
15
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 2_147_483_647)
|
|
16
|
+
throw new RangeError(
|
|
17
|
+
"deferredTimeout must be an integer between 1 and 2147483647 ms",
|
|
18
|
+
);
|
|
19
|
+
|
|
15
20
|
const pending = new Map<number, PendingValue>();
|
|
16
21
|
const queue = ((window as DeferredWindow).__smolterDeferred ??= []);
|
|
17
22
|
|
|
@@ -54,12 +59,20 @@ export function readHydration(json: string) {
|
|
|
54
59
|
|
|
55
60
|
const hydration = JSON.parse(json, revive);
|
|
56
61
|
const dispose = () => {
|
|
62
|
+
clearTimeout(deadline);
|
|
57
63
|
window.removeEventListener("DOMContentLoaded", dispose);
|
|
58
64
|
for (const target of pending.values())
|
|
59
65
|
target.reject(new Error("Hydration stream closed"));
|
|
60
66
|
pending.clear();
|
|
61
67
|
queue.push = () => 0;
|
|
62
68
|
};
|
|
69
|
+
const deadline = pending.size
|
|
70
|
+
? setTimeout(() => {
|
|
71
|
+
for (const target of pending.values())
|
|
72
|
+
target.reject(new Error("Hydration stream timed out"));
|
|
73
|
+
dispose();
|
|
74
|
+
}, timeout)
|
|
75
|
+
: undefined;
|
|
63
76
|
const receive = (packet: Settlement) => {
|
|
64
77
|
const [id, value, error] = packet;
|
|
65
78
|
if (id === -1) return dispose();
|
|
@@ -8,6 +8,10 @@ export interface ApplicationContext {
|
|
|
8
8
|
root: unknown;
|
|
9
9
|
rootError?: boolean;
|
|
10
10
|
streaming?: boolean;
|
|
11
|
+
deferredBoundary?: (
|
|
12
|
+
children: import("preact").ComponentChildren,
|
|
13
|
+
fallback: import("preact").ComponentChildren,
|
|
14
|
+
) => import("preact").ComponentChildren;
|
|
11
15
|
initialData?: string;
|
|
12
16
|
rootModule?: RootModule;
|
|
13
17
|
hydration?: Hydration;
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import type { AppRoute, RootModule } from "../preact/route-types.ts";
|
|
10
10
|
import { prepareRoutes, RouterApplication } from "../preact/router.tsx";
|
|
11
11
|
import { loadRouter } from "./index.ts";
|
|
12
|
+
import type { ComponentChildren } from "preact";
|
|
13
|
+
import { Suspense } from "preact/compat";
|
|
12
14
|
import { renderToReadableStream } from "preact-render-to-string/stream";
|
|
13
15
|
import { deferredHydration } from "./deferred-hydration.ts";
|
|
14
16
|
import { privateResponse } from "./private-response.ts";
|
|
@@ -147,6 +149,7 @@ export async function renderApplication(
|
|
|
147
149
|
entry={entry}
|
|
148
150
|
rootError={Boolean(rootError)}
|
|
149
151
|
streaming
|
|
152
|
+
deferredBoundary={options.earlyHydration ? undefined : deferredBoundary}
|
|
150
153
|
initialData={deferred?.initial}
|
|
151
154
|
document
|
|
152
155
|
/>,
|
|
@@ -181,6 +184,13 @@ export async function renderApplication(
|
|
|
181
184
|
);
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
function deferredBoundary(
|
|
188
|
+
children: ComponentChildren,
|
|
189
|
+
fallback: ComponentChildren,
|
|
190
|
+
) {
|
|
191
|
+
return <Suspense fallback={fallback}>{children}</Suspense>;
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
async function dispatch(
|
|
185
195
|
request: Request,
|
|
186
196
|
routes: AppRoute[],
|
|
@@ -87,10 +87,7 @@ export function lazyFiles(
|
|
|
87
87
|
const source = await readFile(route.file, "utf8");
|
|
88
88
|
const transformed = await transformWithOxc(source, route.file);
|
|
89
89
|
const ast = this.parse(transformed.code);
|
|
90
|
-
const fields: Record<string, string | boolean> = {
|
|
91
|
-
path: route.path,
|
|
92
|
-
dataEndpoint,
|
|
93
|
-
};
|
|
90
|
+
const fields: Record<string, string | boolean> = {};
|
|
94
91
|
|
|
95
92
|
for (const statement of ast.body) {
|
|
96
93
|
if (statement.type === "ExportDefaultDeclaration") {
|
|
@@ -120,22 +117,18 @@ export function lazyFiles(
|
|
|
120
117
|
for (const declaration of statement.declaration.declarations) {
|
|
121
118
|
if (declaration.id.type === "Identifier")
|
|
122
119
|
fields[declaration.id.name] = true;
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
declaration.id.type !== "Identifier" ||
|
|
126
|
-
declaration.init?.type !== "Literal" ||
|
|
127
|
-
typeof declaration.init.value !== "string"
|
|
128
|
-
)
|
|
129
|
-
continue;
|
|
130
|
-
|
|
131
|
-
const name = declaration.id.name;
|
|
132
|
-
|
|
133
|
-
if (name === "id") fields.id = declaration.init.value;
|
|
134
|
-
|
|
135
|
-
if (name === "routePath") fields.path = declaration.init.value;
|
|
136
120
|
}
|
|
137
121
|
}
|
|
138
122
|
|
|
123
|
+
if (fields.id || fields.routePath)
|
|
124
|
+
return this.error(
|
|
125
|
+
`File routes derive identity and paths from filenames: remove id/routePath exports from ${route.file}. Use [.] for a literal dot.`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Export discovery records capabilities, never routing configuration.
|
|
129
|
+
fields.path = route.path;
|
|
130
|
+
fields.dataEndpoint = dataEndpoint;
|
|
131
|
+
|
|
139
132
|
const file = `${route.file.replaceAll("\\", "/")}?smolter-client`;
|
|
140
133
|
|
|
141
134
|
if (fields.default) modules.set(file, String(fields.path));
|
package/.generated/vite/index.ts
CHANGED
|
@@ -152,6 +152,20 @@ export function routerPlugin({
|
|
|
152
152
|
for (const statement of ast.body) {
|
|
153
153
|
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
154
154
|
|
|
155
|
+
const declared = statement.declaration;
|
|
156
|
+
const names = statement.specifiers.map((specifier) =>
|
|
157
|
+
specifier.exported.type === "Identifier"
|
|
158
|
+
? specifier.exported.name
|
|
159
|
+
: specifier.exported.value,
|
|
160
|
+
);
|
|
161
|
+
if (declared?.type === "VariableDeclaration")
|
|
162
|
+
for (const item of declared.declarations)
|
|
163
|
+
if (item.id.type === "Identifier") names.push(item.id.name);
|
|
164
|
+
if (names.some((name) => name === "id" || name === "routePath"))
|
|
165
|
+
return this.error(
|
|
166
|
+
`File routes derive identity and paths from filenames: remove id/routePath exports from ${file}. Use [.] for a literal dot.`,
|
|
167
|
+
);
|
|
168
|
+
|
|
155
169
|
for (const specifier of statement.specifiers) {
|
|
156
170
|
const name =
|
|
157
171
|
specifier.exported.type === "Identifier"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- Add integrated `Await fallback` for early hydration: pending and resolved content belongs to the mounted component, so navigation, root revalidation and late settlement cannot leave abandoned content behind. Migrate early-hydrated deferred sections from outer Preact `Suspense` to this prop, including root sections. Native Preact streamed Suspense remains unsupported for this ownership contract; it is not patched. Ordinary non-early SSR still streams resolved HTML.
|
|
6
|
+
- Preserve deployment recovery ownership across overlapping page/fetcher submissions and watcher replacement, including old cleanup calls. Mutations are not replayed and pending submissions continue to prevent recovery reloads.
|
|
7
|
+
- Bound initial deferred-data hydration with one total deadline (30 seconds by default); validate integer millisecond budgets, reject outstanding promises on expiry and clear lifecycle state on disposal. This is not an inactivity timer or an immediate network-disconnect detector.
|
|
8
|
+
- Support literal dots in flat route filenames and folders: `robots[.]txt` maps to `/robots.txt`. Generated file routes reject `id` and `routePath` overrides; derive identity from their filename and use their path for targeted revalidation. Manually defined route arrays retain explicit identities.
|
|
9
|
+
- Keep the thirteen-case ownership matrix, streaming/CSP/head-preservation checks, scoped transports, deployment recovery and route HMR as production regression gates. Preserve the native Preact diagnostic separately; passing Smolter tests do not claim native Preact or Vibes/device parity.
|
|
10
|
+
|
|
3
11
|
## 0.4.1
|
|
4
12
|
|
|
5
13
|
- Invalidate pending deployment recovery when an independent fetcher submits. Prevent recovery reloads while any tracked fetcher submission is pending, without blocking ordinary client navigation. Install the watcher before creating fetchers; mountApplication already does this.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# smolter _
|
|
2
2
|
|
|
3
3
|
<!-- bundlejs:start -->
|
|
4
|
-
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.2%2Freact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react%2F*%22%5D%7D%7D&bundle)
|
|
5
|
+
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.2%2Fpreact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22preact%22%2C%22preact%2F*%22%5D%7D%7D&bundle)
|
|
6
6
|
<!-- bundlejs:end -->
|
|
7
7
|
|
|
8
8
|
A typed data router for React and Preact. **Zero runtime dependencies.**
|
|
@@ -123,7 +123,11 @@ route folders are ignored. `.ts`, `.tsx`, `.js`, and `.jsx` are supported.
|
|
|
123
123
|
**Only flat matching:** there are no nested outlets, inherited loaders, or layout
|
|
124
124
|
chains. A pathless group module itself is not rendered or executed. Group names
|
|
125
125
|
do not provide authentication. Static paths are ordered before parameters, then
|
|
126
|
-
splats.
|
|
126
|
+
splats. Escape a literal dot with `[.]`: `robots[.]txt.ts` and
|
|
127
|
+
`robots[.]txt/route.ts` both map to `/robots.txt`. File routes derive their
|
|
128
|
+
identity and URL from their filename; do not export `id` or `routePath`.
|
|
129
|
+
Target revalidation with the generated path (or `root` for root data).
|
|
130
|
+
Conflicting patterns and unsupported optional/bracket syntax are rejected.
|
|
127
131
|
|
|
128
132
|
For the virtual module, add this declaration to your app:
|
|
129
133
|
|
|
@@ -604,11 +608,11 @@ a claim of identical features.
|
|
|
604
608
|
| `React: routing-only named exports` | 1245 |
|
|
605
609
|
| `Preact: routing-only named exports` | 1405 |
|
|
606
610
|
| `@kasperrt/smolter/react` | 2948 |
|
|
607
|
-
| `@kasperrt/smolter/preact` |
|
|
608
|
-
| `@kasperrt/smolter/react + @kasperrt/smolter/react/client` |
|
|
609
|
-
| `@kasperrt/smolter/preact + server transport` |
|
|
610
|
-
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` |
|
|
611
|
-
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` |
|
|
611
|
+
| `@kasperrt/smolter/preact` | 5024 |
|
|
612
|
+
| `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4281 |
|
|
613
|
+
| `@kasperrt/smolter/preact + server transport` | 6300 |
|
|
614
|
+
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 9405 |
|
|
615
|
+
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 9692 |
|
|
612
616
|
| `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
|
|
613
617
|
| `React: common routing/data exports` | 2761 |
|
|
614
618
|
| `Preact: common routing/data exports` | 3046 |
|
|
@@ -748,7 +752,7 @@ import { mountApplication } from "@kasperrt/smolter/preact/client";
|
|
|
748
752
|
await mountApplication(routes, root, { deployment });
|
|
749
753
|
```
|
|
750
754
|
|
|
751
|
-
Preact SSR starts root and route loaders concurrently. Return nested promises and
|
|
755
|
+
Preact SSR starts root and route loaders concurrently. Return nested promises and use `Await` with an explicit `fallback` for component-owned pending content during early hydration. The Node/Express adapter streams the response; hydration starts after the document stream finishes by default. Pass `{ earlyHydration: true }` to `renderApplication` to hydrate the shell while optional promises stream independently. Initial deferred values share a total 30-second budget after their payload is read, not an inactivity timeout or reliable disconnect detector. Settlements do not extend it; configure `mountApplication` with `{ deferredTimeout: 60_000 }` for intentionally slower data (integer milliseconds from 1 through 2147483647; invalid values throw RangeError). A broken document can emit no completion event, so this deadline rejects unfinished values without retrying requests. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
|
|
752
756
|
|
|
753
757
|
Successful action redirects refresh registered root data without replaying the mutation. Loader arguments expose `reason` to distinguish initial loads, navigation, revalidation and fetcher reads without comparing browser URLs.
|
|
754
758
|
|
|
@@ -780,3 +784,19 @@ The generated route asset manifest follows static imports for each route and lea
|
|
|
780
784
|
The file-router build emits `.vite/route-assets.json` with each route's static JavaScript dependency closure and its CSS in `styles`. `routePreloads` from `@kasperrt/smolter/utils/assets` produces modulepreload hints for JavaScript and `preload; as=style; crossorigin` hints for CSS (matching Vite’s anonymous CORS mode), respecting your CDN/base URL. Set these hints on the document response or send Early Hints from your host. Stylesheet preloads fetch files; they do not apply styles. Keep the application stylesheet links and Vite's CSS handling in place. Optional dynamic imports are excluded, and unrelated routes stay lazy.
|
|
781
785
|
|
|
782
786
|
Root stylesheet URL imports such as `import stylesUrl from "./index.css?url"` are not route CSS imports. Keep rendering `<link rel="stylesheet" href={stylesUrl}>` in the root document; an empty route `styles` array is expected when all CSS is loaded this way. Smolter does not infer stylesheet use from arbitrary asset URLs. If measurements show late root CSS discovery, your host can send an explicit preload or Early Hint for that same resolved URL, with the same CORS mode as the stylesheet link. Preloading does not replace the stylesheet link. Do not reorganize CSS merely to populate the route manifest.
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
### Component-owned deferred loading
|
|
790
|
+
|
|
791
|
+
For early Preact hydration, pass pending content directly to the built-in `Await`:
|
|
792
|
+
|
|
793
|
+
```tsx
|
|
794
|
+
import { Await } from "@kasperrt/smolter/preact";
|
|
795
|
+
|
|
796
|
+
<Await resolve={data.stats} fallback={<p>Loading statistics…</p>}
|
|
797
|
+
errorElement={<p>Statistics unavailable.</p>}>
|
|
798
|
+
{(stats) => <Stats value={stats} />}
|
|
799
|
+
</Await>
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Replace the surrounding native `Suspense` for each deferred section, including root sections. This uses ordinary component-owned fallback HTML and the existing deferred data stream, avoiding Preact 11.0.1's abandoned streamed-boundary problem. No separate entrypoint, dependency patch, DOM cleanup, extra request or polling is needed. Late results cannot update an unmounted owner. Early-mode resolved content requires JavaScript; ordinary SSR without early hydration still streams resolved HTML. Existing `Await` without `fallback` keeps its Suspense semantics and its known early-hydration limitation. This does not repair arbitrary native Suspense/lazy boundaries.
|
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
import { deploymentCheck } from "./deployment-check.js";
|
|
2
2
|
//#region .generated/client/deployment.ts
|
|
3
|
+
var watchers = /* @__PURE__ */ new WeakMap();
|
|
3
4
|
/** Check for deployment changes before navigation. Install before creating fetchers. Returns a cleanup function. */
|
|
4
5
|
function watchDeployment(router, options) {
|
|
5
6
|
if (!options || typeof window === "undefined") return () => {};
|
|
7
|
+
const state = watchers.get(router) ?? {
|
|
8
|
+
sequence: 0,
|
|
9
|
+
mutations: 0
|
|
10
|
+
};
|
|
11
|
+
state.cleanup?.();
|
|
12
|
+
watchers.set(router, state);
|
|
6
13
|
const check = deploymentCheck(options);
|
|
7
14
|
const { changed, reload } = check;
|
|
8
15
|
const read = router.getSnapshot;
|
|
9
16
|
const navigate = router.navigate;
|
|
10
17
|
const dispose = router.dispose;
|
|
11
|
-
let sequence = 0;
|
|
12
18
|
let stopped = false;
|
|
13
19
|
let replay = false;
|
|
14
|
-
let mutations = 0;
|
|
15
20
|
const dataRouter = router;
|
|
16
21
|
const createFetcher = dataRouter.createFetcher;
|
|
22
|
+
const submit = dataRouter.submit;
|
|
23
|
+
const trackSubmission = (submit) => (...args) => {
|
|
24
|
+
state.sequence++;
|
|
25
|
+
state.mutations++;
|
|
26
|
+
return submit(...args).finally(() => {
|
|
27
|
+
state.mutations--;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const guardedSubmit = submit && trackSubmission(submit);
|
|
17
31
|
const guardedFetcher = createFetcher && (() => {
|
|
18
32
|
const fetcher = createFetcher();
|
|
19
|
-
|
|
20
|
-
fetcher.submit = (...args) => {
|
|
21
|
-
sequence++;
|
|
22
|
-
mutations++;
|
|
23
|
-
return submit(...args).finally(() => {
|
|
24
|
-
mutations--;
|
|
25
|
-
});
|
|
26
|
-
};
|
|
33
|
+
fetcher.submit = trackSubmission(fetcher.submit);
|
|
27
34
|
return fetcher;
|
|
28
35
|
});
|
|
36
|
+
if (guardedSubmit) dataRouter.submit = guardedSubmit;
|
|
29
37
|
if (guardedFetcher) dataRouter.createFetcher = guardedFetcher;
|
|
30
|
-
const valid = (current) => current === sequence && !stopped;
|
|
31
|
-
const canReload = (current) => valid(current) && mutations === 0;
|
|
32
|
-
const unsubscribe = router.subscribe(() => {
|
|
33
|
-
if (read().state === "submitting") sequence++;
|
|
34
|
-
});
|
|
38
|
+
const valid = (current) => current === state.sequence && !stopped;
|
|
39
|
+
const canReload = (current) => valid(current) && state.mutations === 0;
|
|
35
40
|
const guarded = async (href, operation) => {
|
|
36
41
|
const target = new URL(href, router.getSnapshot().url);
|
|
37
42
|
const currentURL = new URL(router.getSnapshot().url);
|
|
38
|
-
const current = ++sequence;
|
|
43
|
+
const current = ++state.sequence;
|
|
39
44
|
if (target.origin === location.origin && (target.pathname !== currentURL.pathname || target.search !== currentURL.search) && await changed()) {
|
|
40
45
|
if (!valid(current) || await reload(target, () => canReload(current))) return;
|
|
41
46
|
}
|
|
42
|
-
if (current === sequence && !stopped) await navigate(href, operation);
|
|
47
|
+
if (current === state.sequence && !stopped) await navigate(href, operation);
|
|
43
48
|
};
|
|
44
49
|
const pop = (event) => {
|
|
45
50
|
if (replay) return;
|
|
46
51
|
const target = new URL(location.href);
|
|
47
52
|
const previous = new URL(read().url);
|
|
48
|
-
const current = ++sequence;
|
|
53
|
+
const current = ++state.sequence;
|
|
49
54
|
if (target.pathname === previous.pathname && target.search === previous.search) return;
|
|
50
55
|
event.stopImmediatePropagation();
|
|
51
56
|
changed().then(async (updated) => {
|
|
@@ -59,20 +64,22 @@ function watchDeployment(router, options) {
|
|
|
59
64
|
};
|
|
60
65
|
const preloadError = () => {
|
|
61
66
|
if (read().state !== "loading") return;
|
|
62
|
-
const current = sequence;
|
|
67
|
+
const current = state.sequence;
|
|
63
68
|
const target = new URL(read().url);
|
|
64
69
|
changed().then((updated) => {
|
|
65
70
|
if (updated) return reload(target, () => canReload(current) && read().state !== "submitting" && read().url === target.href);
|
|
66
71
|
});
|
|
67
72
|
};
|
|
68
73
|
function cleanup() {
|
|
74
|
+
if (stopped) return;
|
|
75
|
+
if (state.cleanup === cleanup) state.cleanup = void 0;
|
|
69
76
|
stopped = true;
|
|
70
|
-
sequence++;
|
|
77
|
+
state.sequence++;
|
|
71
78
|
check.dispose();
|
|
72
|
-
unsubscribe();
|
|
73
79
|
window.removeEventListener("popstate", pop, true);
|
|
74
80
|
window.removeEventListener("vite:preloadError", preloadError);
|
|
75
|
-
if (dataRouter.
|
|
81
|
+
if (guardedSubmit && dataRouter.submit === guardedSubmit) dataRouter.submit = submit;
|
|
82
|
+
if (guardedFetcher && dataRouter.createFetcher === guardedFetcher) dataRouter.createFetcher = createFetcher;
|
|
76
83
|
if (router.navigate === guarded) router.navigate = navigate;
|
|
77
84
|
if (router.dispose === guardedDispose) router.dispose = dispose;
|
|
78
85
|
}
|
|
@@ -80,6 +87,7 @@ function watchDeployment(router, options) {
|
|
|
80
87
|
cleanup();
|
|
81
88
|
dispose();
|
|
82
89
|
}
|
|
90
|
+
state.cleanup = cleanup;
|
|
83
91
|
router.navigate = guarded;
|
|
84
92
|
router.dispose = guardedDispose;
|
|
85
93
|
window.addEventListener("popstate", pop, true);
|
package/dist/files/index.js
CHANGED
|
@@ -26,15 +26,16 @@ async function flatRoutes(directory) {
|
|
|
26
26
|
if (!entry.isFile() || !flatFile.test(name) || name.endsWith(".d.ts")) continue;
|
|
27
27
|
name = name.replace(flatFile, "");
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const escaped = name.replaceAll("[.]", "\0");
|
|
30
|
+
if (/[()[\]:*?]/.test(escaped)) return /* @__PURE__ */ new Error(`Unsupported flat route name: ${name}`);
|
|
31
|
+
const segments = escaped.split(".");
|
|
31
32
|
const parts = [];
|
|
32
33
|
for (const [index, segment] of segments.entries()) {
|
|
33
34
|
if (!segment) return /* @__PURE__ */ new Error(`Empty route segment: ${name}`);
|
|
34
35
|
if (segment === "_index" && index !== segments.length - 1) return /* @__PURE__ */ new Error(`_index must be last: ${name}`);
|
|
35
36
|
if (segment === "$" && index !== segments.length - 1) return /* @__PURE__ */ new Error(`Splat must be last: ${name}`);
|
|
36
37
|
if (segment.startsWith("_")) continue;
|
|
37
|
-
const part = segment.replace(/_$/, "");
|
|
38
|
+
const part = segment.replace(/_$/, "").replaceAll("\0", ".");
|
|
38
39
|
parts.push(part === "$" ? "*" : part.replace(/^\$/, ":"));
|
|
39
40
|
}
|
|
40
41
|
if (segments.at(-1)?.startsWith("_") && segments.at(-1) !== "_index") continue;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
|
+
//#region .generated/preact/await-fallback.tsx
|
|
3
|
+
/** @jsxImportSource preact */
|
|
4
|
+
function AwaitFallback({ resolve, fallback, errorElement, children }) {
|
|
5
|
+
const [result, setResult] = useState();
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
let current = true;
|
|
8
|
+
resolve.then((value) => {
|
|
9
|
+
if (current) setResult({
|
|
10
|
+
source: resolve,
|
|
11
|
+
value
|
|
12
|
+
});
|
|
13
|
+
}, (error) => {
|
|
14
|
+
if (current) setResult({
|
|
15
|
+
source: resolve,
|
|
16
|
+
error
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
return () => {
|
|
20
|
+
current = false;
|
|
21
|
+
};
|
|
22
|
+
}, [resolve]);
|
|
23
|
+
if (!result || result.source !== resolve) return fallback;
|
|
24
|
+
if ("value" in result) return children(result.value);
|
|
25
|
+
if (errorElement !== void 0) return errorElement;
|
|
26
|
+
throw result.error;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { AwaitFallback };
|
package/dist/preact/await.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
1
2
|
import type { ComponentChildren } from "preact";
|
|
2
3
|
export interface AwaitProps<T> {
|
|
3
4
|
resolve: T | Promise<T>;
|
|
4
5
|
children: (value: Awaited<T>) => ComponentChildren;
|
|
5
6
|
errorElement?: ComponentChildren;
|
|
7
|
+
/** Component-owned pending content, safe during early hydration. Pass null to render nothing. */
|
|
8
|
+
fallback?: ComponentChildren;
|
|
6
9
|
}
|
|
7
|
-
export declare function Await<T>(
|
|
10
|
+
export declare function Await<T>(props: AwaitProps<T>): ComponentChildren;
|
package/dist/preact/await.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AwaitFallback } from "./await-fallback.js";
|
|
2
|
+
import { Application } from "./root-context.js";
|
|
3
|
+
import { use, useContext } from "preact/compat";
|
|
4
|
+
import { jsx } from "preact/jsx-runtime";
|
|
2
5
|
//#region .generated/preact/await.tsx
|
|
3
6
|
var resolutions = /* @__PURE__ */ new WeakMap();
|
|
4
|
-
function Await(
|
|
5
|
-
|
|
7
|
+
function Await(props) {
|
|
8
|
+
const app = useContext(Application);
|
|
9
|
+
if (!(props.resolve instanceof Promise)) return props.children(props.resolve);
|
|
10
|
+
if (!("fallback" in props)) return /* @__PURE__ */ jsx(SuspendedAwait, {
|
|
11
|
+
...props,
|
|
12
|
+
resolve: props.resolve
|
|
13
|
+
});
|
|
14
|
+
if (app?.deferredBoundary) return app.deferredBoundary(/* @__PURE__ */ jsx(SuspendedAwait, {
|
|
15
|
+
...props,
|
|
16
|
+
resolve: props.resolve
|
|
17
|
+
}), props.fallback);
|
|
18
|
+
return /* @__PURE__ */ jsx(AwaitFallback, {
|
|
19
|
+
...props,
|
|
20
|
+
resolve: props.resolve
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function SuspendedAwait({ resolve, children, errorElement }) {
|
|
6
24
|
let pending = resolutions.get(resolve);
|
|
7
25
|
if (!pending) {
|
|
8
26
|
pending = resolve.then((value) => ({ value }), (error) => ({ error }));
|
package/dist/preact/client.d.ts
CHANGED
|
@@ -8,5 +8,7 @@ export { configureApplication } from "../core/environment.ts";
|
|
|
8
8
|
export interface MountApplicationOptions {
|
|
9
9
|
hash?: boolean;
|
|
10
10
|
deployment?: DeploymentOptions | null;
|
|
11
|
+
/** Total initial deferred-data budget in integer milliseconds; not an inactivity timeout. Defaults to 30,000. */
|
|
12
|
+
deferredTimeout?: number;
|
|
11
13
|
}
|
|
12
14
|
export declare function mountApplication(routes: AppRoute[], rootModule?: RootModule, options?: boolean | MountApplicationOptions): Promise<Router>;
|
package/dist/preact/client.js
CHANGED
|
@@ -16,7 +16,7 @@ import { hydrate, render } from "preact";
|
|
|
16
16
|
/** Browser mounting and hydration for Preact applications. @module */
|
|
17
17
|
/** @jsxImportSource preact */
|
|
18
18
|
async function mountApplication(routes, rootModule, options = false) {
|
|
19
|
-
const { hash = false, deployment = null } = typeof options === "boolean" ? { hash: options } : options;
|
|
19
|
+
const { hash = false, deployment = null, deferredTimeout } = typeof options === "boolean" ? { hash: options } : options;
|
|
20
20
|
const initialURL = new URL(hash ? location.hash.slice(1) || "/" : location.href, location.origin);
|
|
21
21
|
await (matchRoutes(routes, initialURL.pathname)?.route)?.preload?.().catch(async (error) => {
|
|
22
22
|
await recoverDeployment(deployment, initialURL);
|
|
@@ -24,7 +24,7 @@ async function mountApplication(routes, rootModule, options = false) {
|
|
|
24
24
|
});
|
|
25
25
|
routes = prepareRoutes(routes);
|
|
26
26
|
const element = document.getElementById("smolter-data");
|
|
27
|
-
const initial = element?.textContent ? readHydration(element.textContent) : void 0;
|
|
27
|
+
const initial = element?.textContent ? readHydration(element.textContent, deferredTimeout) : void 0;
|
|
28
28
|
const hydration = initial?.hydration;
|
|
29
29
|
if (hydration?.snapshot.error) hydration.snapshot.error = Object.assign(new Error(hydration.snapshot.error.message), hydration.snapshot.error);
|
|
30
30
|
const router = createDataRouter(routes, {
|
|
@@ -3,7 +3,7 @@ export interface DeferredWindow extends Window {
|
|
|
3
3
|
__smolterDeferred?: Settlement[];
|
|
4
4
|
}
|
|
5
5
|
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
6
|
-
export declare function readHydration(json: string): {
|
|
6
|
+
export declare function readHydration(json: string, timeout?: number): {
|
|
7
7
|
hydration: any;
|
|
8
8
|
dispose: () => void;
|
|
9
9
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//#region .generated/preact/deferred-hydration.ts
|
|
2
2
|
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
3
|
-
function readHydration(json) {
|
|
3
|
+
function readHydration(json, timeout = 3e4) {
|
|
4
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 2147483647) throw new RangeError("deferredTimeout must be an integer between 1 and 2147483647 ms");
|
|
4
5
|
const pending = /* @__PURE__ */ new Map();
|
|
5
6
|
const queue = window.__smolterDeferred ??= [];
|
|
6
7
|
function revive(_key, value) {
|
|
@@ -34,11 +35,16 @@ function readHydration(json) {
|
|
|
34
35
|
}
|
|
35
36
|
const hydration = JSON.parse(json, revive);
|
|
36
37
|
const dispose = () => {
|
|
38
|
+
clearTimeout(deadline);
|
|
37
39
|
window.removeEventListener("DOMContentLoaded", dispose);
|
|
38
40
|
for (const target of pending.values()) target.reject(/* @__PURE__ */ new Error("Hydration stream closed"));
|
|
39
41
|
pending.clear();
|
|
40
42
|
queue.push = () => 0;
|
|
41
43
|
};
|
|
44
|
+
const deadline = pending.size ? setTimeout(() => {
|
|
45
|
+
for (const target of pending.values()) target.reject(/* @__PURE__ */ new Error("Hydration stream timed out"));
|
|
46
|
+
dispose();
|
|
47
|
+
}, timeout) : void 0;
|
|
42
48
|
const receive = (packet) => {
|
|
43
49
|
const [id, value, error] = packet;
|
|
44
50
|
if (id === -1) return dispose();
|
package/dist/preact/document.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { serializeData } from "../core/serialize.js";
|
|
2
|
+
import { useApplication } from "./root-context.js";
|
|
2
3
|
import { useSnapshot } from "./context.js";
|
|
3
4
|
import { applicationEnvironment } from "../core/environment.js";
|
|
4
5
|
import { scrollPositions } from "../core/navigation.js";
|
|
5
6
|
import { useNavigationSnapshot } from "./navigation-state.js";
|
|
6
|
-
import { useApplication } from "./root-context.js";
|
|
7
7
|
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
8
8
|
import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
|
|
9
9
|
import { Fragment as Fragment$1, createElement } from "preact";
|
|
@@ -6,6 +6,7 @@ export interface ApplicationContext {
|
|
|
6
6
|
root: unknown;
|
|
7
7
|
rootError?: boolean;
|
|
8
8
|
streaming?: boolean;
|
|
9
|
+
deferredBoundary?: (children: import("preact").ComponentChildren, fallback: import("preact").ComponentChildren) => import("preact").ComponentChildren;
|
|
9
10
|
initialData?: string;
|
|
10
11
|
rootModule?: RootModule;
|
|
11
12
|
hydration?: Hydration;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { submissionBody, submissionSearch } from "../core/submission.js";
|
|
2
2
|
import { useContext, useMemo } from "./runtime.js";
|
|
3
|
+
import { OutletContext, useApplication } from "./root-context.js";
|
|
3
4
|
import { useRouter, useSnapshot } from "./context.js";
|
|
4
5
|
import { pendingLocation } from "../core/presentation.js";
|
|
5
6
|
import { useNavigationSnapshot } from "./navigation-state.js";
|
|
6
|
-
import { OutletContext, useApplication } from "./root-context.js";
|
|
7
7
|
import { useCallback } from "preact/hooks";
|
|
8
8
|
//#region .generated/preact/route-hooks.tsx
|
|
9
9
|
function useRootData() {
|
package/dist/preact/router.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { normalizeResult } from "../core/responses.js";
|
|
2
2
|
import { useContext, useMemo, useSyncExternalStore } from "./runtime.js";
|
|
3
|
+
import { Application, OutletContext } from "./root-context.js";
|
|
3
4
|
import { RouterProvider } from "./context.js";
|
|
4
5
|
import { createStore } from "../core/store.js";
|
|
5
|
-
import { Application, OutletContext } from "./root-context.js";
|
|
6
6
|
import { Routes } from "./routes.js";
|
|
7
7
|
import { jsx } from "preact/jsx-runtime";
|
|
8
8
|
//#region .generated/preact/router.tsx
|
|
@@ -8,6 +8,7 @@ import { loadRouter } from "./index.js";
|
|
|
8
8
|
import { deferredHydration } from "./deferred-hydration.js";
|
|
9
9
|
import { progressiveDocument } from "./progressive-document.js";
|
|
10
10
|
import { resolveHydration, streamDocument } from "./stream-document.js";
|
|
11
|
+
import { Suspense } from "preact/compat";
|
|
11
12
|
import { jsx } from "preact/jsx-runtime";
|
|
12
13
|
import { renderToReadableStream } from "preact-render-to-string/stream";
|
|
13
14
|
//#region .generated/server/application.tsx
|
|
@@ -91,6 +92,7 @@ async function renderApplication(request, routes, rootModule, entry, options = {
|
|
|
91
92
|
entry,
|
|
92
93
|
rootError: Boolean(rootError),
|
|
93
94
|
streaming: true,
|
|
95
|
+
deferredBoundary: options.earlyHydration ? void 0 : deferredBoundary,
|
|
94
96
|
initialData: deferred?.initial,
|
|
95
97
|
document: true
|
|
96
98
|
}), options);
|
|
@@ -113,6 +115,12 @@ async function renderApplication(request, routes, rootModule, entry, options = {
|
|
|
113
115
|
headers
|
|
114
116
|
});
|
|
115
117
|
}
|
|
118
|
+
function deferredBoundary(children, fallback) {
|
|
119
|
+
return /* @__PURE__ */ jsx(Suspense, {
|
|
120
|
+
fallback,
|
|
121
|
+
children
|
|
122
|
+
});
|
|
123
|
+
}
|
|
116
124
|
async function dispatch(request, routes, document = false) {
|
|
117
125
|
const match = matchRoutes(routes.map((route) => ({
|
|
118
126
|
...route,
|
|
@@ -56,10 +56,7 @@ function lazyFiles(directory, dataEndpoint = "/_smolter") {
|
|
|
56
56
|
const source = await readFile(route.file, "utf8");
|
|
57
57
|
const transformed = await transformWithOxc(source, route.file);
|
|
58
58
|
const ast = this.parse(transformed.code);
|
|
59
|
-
const fields = {
|
|
60
|
-
path: route.path,
|
|
61
|
-
dataEndpoint
|
|
62
|
-
};
|
|
59
|
+
const fields = {};
|
|
63
60
|
for (const statement of ast.body) {
|
|
64
61
|
if (statement.type === "ExportDefaultDeclaration") {
|
|
65
62
|
fields.default = true;
|
|
@@ -75,14 +72,11 @@ function lazyFiles(directory, dataEndpoint = "/_smolter") {
|
|
|
75
72
|
fields[name] = true;
|
|
76
73
|
}
|
|
77
74
|
if (statement.declaration?.type !== "VariableDeclaration") continue;
|
|
78
|
-
for (const declaration of statement.declaration.declarations)
|
|
79
|
-
if (declaration.id.type === "Identifier") fields[declaration.id.name] = true;
|
|
80
|
-
if (declaration.id.type !== "Identifier" || declaration.init?.type !== "Literal" || typeof declaration.init.value !== "string") continue;
|
|
81
|
-
const name = declaration.id.name;
|
|
82
|
-
if (name === "id") fields.id = declaration.init.value;
|
|
83
|
-
if (name === "routePath") fields.path = declaration.init.value;
|
|
84
|
-
}
|
|
75
|
+
for (const declaration of statement.declaration.declarations) if (declaration.id.type === "Identifier") fields[declaration.id.name] = true;
|
|
85
76
|
}
|
|
77
|
+
if (fields.id || fields.routePath) return this.error(`File routes derive identity and paths from filenames: remove id/routePath exports from ${route.file}. Use [.] for a literal dot.`);
|
|
78
|
+
fields.path = route.path;
|
|
79
|
+
fields.dataEndpoint = dataEndpoint;
|
|
86
80
|
const file = `${route.file.replaceAll("\\", "/")}?smolter-client`;
|
|
87
81
|
if (fields.default) modules.set(file, String(fields.path));
|
|
88
82
|
definitions.push(`lazyRoute(${JSON.stringify(fields)}, () => import(${JSON.stringify(file)}))`);
|
package/dist/vite/index.js
CHANGED
|
@@ -69,6 +69,12 @@ function routerPlugin({ directory = "src/routes", dataEndpoint = "/_smolter", cl
|
|
|
69
69
|
const handlers = {};
|
|
70
70
|
for (const statement of ast.body) {
|
|
71
71
|
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
72
|
+
const declared = statement.declaration;
|
|
73
|
+
const names = statement.specifiers.map((specifier) => specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value);
|
|
74
|
+
if (declared?.type === "VariableDeclaration") {
|
|
75
|
+
for (const item of declared.declarations) if (item.id.type === "Identifier") names.push(item.id.name);
|
|
76
|
+
}
|
|
77
|
+
if (names.some((name) => name === "id" || name === "routePath")) return this.error(`File routes derive identity and paths from filenames: remove id/routePath exports from ${file}. Use [.] for a literal dot.`);
|
|
72
78
|
for (const specifier of statement.specifiers) {
|
|
73
79
|
const name = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value;
|
|
74
80
|
if (name === "loader" || name === "action") handlers[name] = true;
|
package/jsr.json
CHANGED