@kasperrt/smolter 0.3.4 → 0.4.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.
@@ -11,6 +11,7 @@ import { connectNavigation } from "../core/navigation.ts";
11
11
  import { retainRoute } from "../core/presentation.ts";
12
12
  import { hydrate, render } from "preact";
13
13
  import { flushSync } from "preact/compat";
14
+ import { readHydration } from "./deferred-hydration.ts";
14
15
  import { attachRootData } from "./root-data.ts";
15
16
  import type { AppRoute, Hydration, RootModule } from "./route-types.ts";
16
17
  import { prepareRoutes, RouterApplication } from "./router.tsx";
@@ -46,25 +47,10 @@ export async function mountApplication(
46
47
 
47
48
  routes = prepareRoutes(routes);
48
49
  const element = document.getElementById("smolter-data");
49
- const hydration: Hydration | undefined = element?.textContent
50
- ? JSON.parse(element.textContent, (_key, value) => {
51
- if (
52
- !value ||
53
- typeof value !== "object" ||
54
- !("__smolterRejected" in value)
55
- )
56
- return value;
57
-
58
- const error = Object.assign(
59
- new Error(value.__smolterRejected.message),
60
- value.__smolterRejected,
61
- );
62
- const rejected = Promise.reject(error);
63
- void rejected.catch(() => {});
64
-
65
- return rejected;
66
- })
50
+ const initial = element?.textContent
51
+ ? readHydration(element.textContent)
67
52
  : undefined;
53
+ const hydration: Hydration | undefined = initial?.hydration;
68
54
 
69
55
  if (hydration?.snapshot.error)
70
56
  hydration.snapshot.error = Object.assign(
@@ -79,6 +65,11 @@ export async function mountApplication(
79
65
  : location.href,
80
66
  ...(hydration && { hydrationData: hydration.snapshot }),
81
67
  });
68
+ const dispose = router.dispose;
69
+ router.dispose = () => {
70
+ initial?.dispose();
71
+ dispose();
72
+ };
82
73
  watchDeployment(router, deployment);
83
74
  if (hash) connectHashHistory(router);
84
75
 
@@ -96,7 +87,21 @@ export async function mountApplication(
96
87
  };
97
88
 
98
89
  if (hydration) {
99
- hydrate(<RouterApplication {...props} document />, document);
90
+ // Explicitly unmanaged head nodes must not participate in positional hydration.
91
+ const preserved = Array.from(
92
+ document.querySelectorAll(
93
+ "head > [data-smolter-preserve], body > [data-smolter-stream]",
94
+ ),
95
+ (node) => ({ node, next: node.nextSibling, parent: node.parentNode! }),
96
+ );
97
+ for (const { node } of preserved) node.remove();
98
+
99
+ await Promise.resolve()
100
+ .then(() => hydrate(<RouterApplication {...props} document />, document))
101
+ .finally(() => {
102
+ for (const { node, next, parent } of preserved.reverse())
103
+ parent.insertBefore(node, next?.parentNode === parent ? next : null);
104
+ });
100
105
 
101
106
  return router;
102
107
  }
@@ -0,0 +1,87 @@
1
+ type Settlement = [number, unknown, string | null];
2
+
3
+ interface PendingValue {
4
+ promise: Promise<unknown>;
5
+ resolve: (value: unknown) => void;
6
+ reject: (reason: Error) => void;
7
+ }
8
+
9
+ declare global {
10
+ interface Window {
11
+ __smolterDeferred?: Settlement[];
12
+ }
13
+ }
14
+
15
+ /** Restore promise identity before hydrating Suspense boundaries. */
16
+ export function readHydration(json: string) {
17
+ const pending = new Map<number, PendingValue>();
18
+ const queue = (window.__smolterDeferred ??= []);
19
+
20
+ function revive(_key: string, value: unknown): unknown {
21
+ if (!value || typeof value !== "object") return value;
22
+ if ("__smolterUndefined" in value) return undefined;
23
+
24
+ if (
25
+ "__smolterPending" in value &&
26
+ typeof value.__smolterPending === "number"
27
+ ) {
28
+ const id = value.__smolterPending;
29
+ const existing = pending.get(id);
30
+ if (existing) return existing.promise;
31
+
32
+ let resolve!: PendingValue["resolve"];
33
+ let reject!: PendingValue["reject"];
34
+ const promise = new Promise((accept, fail) => {
35
+ resolve = accept;
36
+ reject = fail;
37
+ });
38
+ void promise.catch(() => {});
39
+ pending.set(id, { promise, resolve, reject });
40
+
41
+ return promise;
42
+ }
43
+
44
+ if ("__smolterRejected" in value) {
45
+ const failure = value.__smolterRejected as { message: string };
46
+ const rejected = Promise.reject(
47
+ Object.assign(new Error(failure.message), failure),
48
+ );
49
+ void rejected.catch(() => {});
50
+
51
+ return rejected;
52
+ }
53
+
54
+ return value;
55
+ }
56
+
57
+ const hydration = JSON.parse(json, revive);
58
+ const dispose = () => {
59
+ window.removeEventListener("DOMContentLoaded", dispose);
60
+ for (const target of pending.values())
61
+ target.reject(new Error("Hydration stream closed"));
62
+ pending.clear();
63
+ queue.push = () => 0;
64
+ };
65
+ const receive = (packet: Settlement) => {
66
+ const [id, value, error] = packet;
67
+ if (id === -1) return dispose();
68
+
69
+ const target = pending.get(id);
70
+ if (!target) return;
71
+
72
+ if (error !== null) target.reject(new Error(error));
73
+ if (error === null)
74
+ target.resolve(JSON.parse(JSON.stringify(value), revive));
75
+ };
76
+ const buffered = queue.splice(0);
77
+ queue.push = (...packets) => {
78
+ packets.forEach(receive);
79
+ return 0;
80
+ };
81
+
82
+ window.addEventListener("DOMContentLoaded", dispose, { once: true });
83
+ buffered.forEach(receive);
84
+ if (document.readyState !== "loading") dispose();
85
+
86
+ return { hydration, dispose };
87
+ }
@@ -72,10 +72,12 @@ export interface ScriptProps {
72
72
  }
73
73
 
74
74
  export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
75
- const { root, rootError, router, entry, streaming } = useApplication();
75
+ const { root, rootError, router, entry, streaming, initialData } =
76
+ useApplication();
76
77
  // The bootstrap payload describes this document's initial SSR snapshot.
77
78
  // Navigation and revalidation update the router, not the hydration script.
78
79
  const [json] = useState(() => {
80
+ if (initialData !== undefined) return initialData;
79
81
  if (streaming) return "";
80
82
 
81
83
  const current = router.getSnapshot();
@@ -105,7 +107,14 @@ export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
105
107
  {...(nonce && { nonce })}
106
108
  dangerouslySetInnerHTML={{ __html: json }}
107
109
  />
108
- {entry && <script type="module" src={entry} {...(nonce && { nonce })} />}
110
+ {entry && (
111
+ <script
112
+ type="module"
113
+ async={initialData !== undefined}
114
+ src={entry}
115
+ {...(nonce && { nonce })}
116
+ />
117
+ )}
109
118
  </Fragment>
110
119
  );
111
120
  }
@@ -8,6 +8,7 @@ export interface ApplicationContext {
8
8
  root: unknown;
9
9
  rootError?: boolean;
10
10
  streaming?: boolean;
11
+ initialData?: string;
11
12
  rootModule?: RootModule;
12
13
  hydration?: Hydration;
13
14
  entry?: string;
@@ -10,11 +10,14 @@ 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
12
  import { renderToReadableStream } from "preact-render-to-string/stream";
13
+ import { deferredHydration } from "./deferred-hydration.ts";
13
14
  import { privateResponse } from "./private-response.ts";
15
+ import { progressiveDocument } from "./progressive-document.ts";
14
16
  import { resolveHydration, streamDocument } from "./stream-document.ts";
15
17
 
16
18
  export interface RenderApplicationOptions {
17
19
  nonce?: string;
20
+ earlyHydration?: boolean;
18
21
  }
19
22
 
20
23
  export async function renderApplication(
@@ -120,6 +123,21 @@ export async function renderApplication(
120
123
  }
121
124
 
122
125
  const snapshot = router.getSnapshot();
126
+ const hydrationData = {
127
+ root,
128
+ snapshot: {
129
+ ...snapshot,
130
+ error: snapshot.error && {
131
+ ...snapshot.error,
132
+ name: snapshot.error.name,
133
+ message: snapshot.error.message,
134
+ },
135
+ },
136
+ ...(rootError && { rootError: true }),
137
+ };
138
+ const deferred = options.earlyHydration
139
+ ? deferredHydration(hydrationData, options.nonce)
140
+ : undefined;
123
141
  const stream = renderToReadableStream(
124
142
  <RouterApplication
125
143
  router={router}
@@ -129,22 +147,11 @@ export async function renderApplication(
129
147
  entry={entry}
130
148
  rootError={Boolean(rootError)}
131
149
  streaming
150
+ initialData={deferred?.initial}
132
151
  document
133
152
  />,
134
153
  options,
135
154
  );
136
- const hydration = resolveHydration({
137
- root,
138
- snapshot: {
139
- ...snapshot,
140
- error: snapshot.error && {
141
- ...snapshot.error,
142
- name: snapshot.error.name,
143
- message: snapshot.error.message,
144
- },
145
- },
146
- ...(rootError && { rootError: true }),
147
- });
148
155
  // The renderer reports the same rejection through its stream reader.
149
156
  void stream.allReady.catch(() => {});
150
157
  const headers = new Headers(actionResponse?.headers);
@@ -154,12 +161,17 @@ export async function renderApplication(
154
161
  headers.set("Cache-Control", "no-store");
155
162
 
156
163
  return new Response(
157
- streamDocument(stream, {
158
- hydration,
159
- signal: request.signal,
160
- nonce: options.nonce,
161
- dispose: () => router.dispose(),
162
- }),
164
+ deferred
165
+ ? progressiveDocument(stream, deferred.stream, {
166
+ signal: request.signal,
167
+ dispose: () => router.dispose(),
168
+ })
169
+ : streamDocument(stream, {
170
+ hydration: resolveHydration(hydrationData),
171
+ signal: request.signal,
172
+ nonce: options.nonce,
173
+ dispose: () => router.dispose(),
174
+ }),
163
175
  {
164
176
  status: snapshot.error
165
177
  ? Number("status" in snapshot.error ? snapshot.error.status : 500)
@@ -3,6 +3,7 @@ import { matchRoutes } from "../core/index.ts";
3
3
  export interface RouteAssets {
4
4
  path: string;
5
5
  files: string[];
6
+ styles?: string[];
6
7
  }
7
8
 
8
9
  export function routePreloads(
@@ -13,7 +14,14 @@ export function routePreloads(
13
14
  const match = matchRoutes(routes, pathname);
14
15
  const route = match?.route as RouteAssets | undefined;
15
16
 
16
- return (route?.files ?? [])
17
- .map((file) => `<${base.replace(/\/$/, "")}/${file}>; rel=modulepreload`)
18
- .join(", ");
17
+ const prefix = base.replace(/\/$/, "");
18
+
19
+ return [
20
+ ...(route?.styles ?? []).map(
21
+ (file) => `<${prefix}/${file}>; rel=preload; as=style; crossorigin`,
22
+ ),
23
+ ...(route?.files ?? []).map(
24
+ (file) => `<${prefix}/${file}>; rel=modulepreload`,
25
+ ),
26
+ ].join(", ");
19
27
  }
@@ -0,0 +1,89 @@
1
+ import { serializeData } from "../core/serialize.ts";
2
+
3
+ /** Encode deferred values without delaying the initial document snapshot. */
4
+ export function deferredHydration(value: unknown, nonce?: string) {
5
+ const promises = new Map<Promise<unknown>, number>();
6
+ let pending = 0;
7
+ let closed = false;
8
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
9
+ const encoder = new TextEncoder();
10
+ const attribute = nonce
11
+ ? ` nonce="${nonce.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}"`
12
+ : "";
13
+ const stream = new ReadableStream<Uint8Array>({
14
+ start(value) {
15
+ controller = value;
16
+ },
17
+ cancel() {
18
+ closed = true;
19
+ },
20
+ });
21
+
22
+ function encode(value: unknown): unknown {
23
+ if (value instanceof Promise) {
24
+ const existing = promises.get(value);
25
+ if (existing !== undefined) return { __smolterPending: existing };
26
+
27
+ const id = promises.size;
28
+ promises.set(value, id);
29
+ pending++;
30
+ void value
31
+ .then((result) => settle(id, encode(result)))
32
+ .catch((error: unknown) =>
33
+ settle(
34
+ id,
35
+ null,
36
+ error instanceof Error ? error.message : String(error),
37
+ ),
38
+ );
39
+
40
+ return { __smolterPending: id };
41
+ }
42
+
43
+ if (Array.isArray(value)) return value.map(encode);
44
+
45
+ if (
46
+ value &&
47
+ typeof value === "object" &&
48
+ Object.getPrototypeOf(value) === Object.prototype
49
+ )
50
+ return Object.fromEntries(
51
+ Object.entries(value).map(([key, field]) => [key, encode(field)]),
52
+ );
53
+
54
+ return value;
55
+ }
56
+
57
+ function settle(id: number, value: unknown, error?: string) {
58
+ if (closed) return;
59
+
60
+ const packet = serializeData([
61
+ id,
62
+ value === undefined ? { __smolterUndefined: true } : value,
63
+ error,
64
+ ]);
65
+ controller.enqueue(
66
+ encoder.encode(
67
+ `<script${attribute}>(window.__smolterDeferred??=[]).push(${packet});document.currentScript.remove()</script>`,
68
+ ),
69
+ );
70
+ pending--;
71
+ if (!pending) {
72
+ closed = true;
73
+ controller.enqueue(
74
+ encoder.encode(
75
+ `<script${attribute}>(window.__smolterDeferred??=[]).push([-1,null,null]);document.currentScript.remove()</script>`,
76
+ ),
77
+ );
78
+ controller.close();
79
+ }
80
+ }
81
+
82
+ const initial = serializeData(encode(value));
83
+ if (!pending) {
84
+ closed = true;
85
+ controller.close();
86
+ }
87
+
88
+ return { initial, stream };
89
+ }
@@ -0,0 +1,102 @@
1
+ interface ProgressiveDocumentOptions {
2
+ signal: AbortSignal;
3
+ dispose: () => void;
4
+ }
5
+
6
+ /** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
7
+ export function progressiveDocument(
8
+ source: ReadableStream<Uint8Array>,
9
+ settlements: ReadableStream<Uint8Array>,
10
+ options: ProgressiveDocumentOptions,
11
+ ): ReadableStream<Uint8Array> {
12
+ const html = source.getReader();
13
+ const data = settlements.getReader();
14
+ const encoder = new TextEncoder();
15
+ const decoder = new TextDecoder();
16
+ let stopped = false;
17
+ let cancelled = false;
18
+ let first = true;
19
+ let suffix = "";
20
+ const readHTML = () =>
21
+ html.read().then(
22
+ (result) => ({ ...result, html: true }),
23
+ (error: unknown) => ({ error }),
24
+ );
25
+ const readData = () =>
26
+ data.read().then(
27
+ (result) => ({ ...result, html: false }),
28
+ (error: unknown) => ({ error }),
29
+ );
30
+ let nextHTML: ReturnType<typeof readHTML> | undefined;
31
+ let nextData: ReturnType<typeof readData> | undefined;
32
+
33
+ const stop = () => {
34
+ if (stopped) return;
35
+
36
+ stopped = true;
37
+ options.signal.removeEventListener("abort", stop);
38
+ options.dispose();
39
+ void html.cancel().catch(() => {});
40
+ void data.cancel().catch(() => {});
41
+ };
42
+ options.signal.addEventListener("abort", stop, { once: true });
43
+ if (options.signal.aborted) stop();
44
+
45
+ return new ReadableStream({
46
+ async pull(controller) {
47
+ const work = async () => {
48
+ if (first && !stopped) nextHTML = readHTML();
49
+
50
+ while (!stopped && (nextHTML || nextData)) {
51
+ const reads = [nextHTML, nextData].filter(
52
+ (value) => value !== undefined,
53
+ );
54
+ const result = await Promise.race(reads);
55
+ if (stopped) break;
56
+ if ("error" in result) return Promise.reject(result.error);
57
+
58
+ if (result.html) nextHTML = result.done ? undefined : readHTML();
59
+ if (!result.html) nextData = result.done ? undefined : readData();
60
+ if (first) nextData = readData();
61
+ if (result.done) {
62
+ first = false;
63
+ continue;
64
+ }
65
+
66
+ if (!result.html) {
67
+ controller.enqueue(result.value);
68
+ return;
69
+ }
70
+
71
+ let chunk = decoder.decode(result.value, { stream: true });
72
+ if (chunk === "<div hidden>")
73
+ chunk = "<div hidden data-smolter-stream>";
74
+ if (first && !/^<!doctype/i.test(chunk))
75
+ chunk = `<!doctype html>${chunk}`;
76
+ first = false;
77
+ const closing = chunk.lastIndexOf("</body>");
78
+ if (closing !== -1) {
79
+ suffix = chunk.slice(closing);
80
+ chunk = chunk.slice(0, closing);
81
+ }
82
+ controller.enqueue(encoder.encode(chunk));
83
+ return;
84
+ }
85
+
86
+ if (cancelled) return;
87
+ if (!stopped) controller.enqueue(encoder.encode(suffix));
88
+ stop();
89
+ controller.close();
90
+ };
91
+
92
+ await work().catch((error: unknown) => {
93
+ stop();
94
+ controller.error(error);
95
+ });
96
+ },
97
+ cancel() {
98
+ cancelled = true;
99
+ stop();
100
+ },
101
+ });
102
+ }
@@ -3,6 +3,10 @@ import { resolve } from "node:path";
3
3
  import { flatRoutes } from "../../files/index.ts";
4
4
  import { type Plugin, transformWithOxc } from "vite";
5
5
 
6
+ interface ChunkMetadata {
7
+ importedCss?: Set<string>;
8
+ }
9
+
6
10
  // Reuse Smolter's file discovery and server-export stripping. Only the browser
7
11
  // route manifest changes: matched routes load before their data is committed.
8
12
  export function lazyFiles(
@@ -31,21 +35,27 @@ export function lazyFiles(
31
35
  if (!path) continue;
32
36
 
33
37
  const files = new Set<string>();
38
+ const styles = new Set<string>();
34
39
 
35
40
  function visit(file: string) {
36
41
  if (files.has(file)) return;
37
42
 
38
- files.add(file);
39
-
40
43
  const dependency = bundle[file];
41
44
 
42
45
  if (dependency?.type !== "chunk") return;
43
46
 
47
+ files.add(file);
48
+
49
+ const metadata = dependency.viteMetadata as ChunkMetadata | undefined;
50
+ for (const stylesheet of metadata?.importedCss ?? []) {
51
+ if (bundle[stylesheet]?.type === "asset") styles.add(stylesheet);
52
+ }
53
+
44
54
  for (const imported of dependency.imports) visit(imported);
45
55
  }
46
56
 
47
57
  visit(chunk.fileName);
48
- routes.push({ path, files: [...files] });
58
+ routes.push({ path, files: [...files], styles: [...styles] });
49
59
  }
50
60
 
51
61
  const paths = [...modules.values()];
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Add opt-in `earlyHydration: true` to Preact `renderApplication`: hydrate the shell before optional nested loader promises settle, using Preact Suspense and independently streamed values/errors. Default hydration timing is unchanged.
6
+ - Preserve shared promise identity, nonce-aware script serialization and the renderer's streaming container. Reject unfinished values when the document stream ends or the router is disposed; cancel server readers on disconnect.
7
+ - Add production regressions for interaction and input retention during a 1,500 ms delay, independent deferred boundaries, rejection, one-time mutation, navigation before settlement, injected head nodes and CSP nonces. These are fixture results, not Vibes performance or device sign-off.
8
+ - Include matched-route CSS in generated asset manifests and stylesheet preload hints using Vite’s anonymous CORS mode. Verify cross-origin preload reuse in Chromium. Keep transitive JavaScript dependencies discoverable and optional dynamic imports lazy. Preload hints do not apply styles or imply a measured latency improvement.
9
+ - Update SSR and loading documentation, including removal of stale claims that the Express adapter buffers responses.
10
+
11
+ ## 0.3.5
12
+
13
+ - Preserve explicitly unmanaged head elements marked data-smolter-preserve during Preact document hydration, without matching them to application-owned nodes.
14
+ - Test injected script/meta preservation, one-time script execution, styling and subsequent navigation.
15
+ - Add interaction-before/after-deferred-completion assertions and document that streaming does not provide progressive hydration.
16
+ - Fix route asset manifests incorrectly including external module specifiers as preload file URLs; retain transitive emitted chunks and leave optional dynamic imports lazy. No general request-count or latency improvement is claimed.
17
+
3
18
  ## 0.3.4
4
19
 
5
20
  - Cancel stale deployment checks when newer Back/Forward operations return to the current route, including hash changes.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # smolter _
2
2
 
3
3
  <!-- bundlejs:start -->
4
- [![react gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.4%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&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.4%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
- [![preact gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.4%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&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.4%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)
4
+ [![react gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%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&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%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
+ [![preact gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%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&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%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.**
@@ -607,8 +607,8 @@ a claim of identical features.
607
607
  | `@kasperrt/smolter/preact` | 4842 |
608
608
  | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4104 |
609
609
  | `@kasperrt/smolter/preact + server transport` | 5950 |
610
- | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8478 |
611
- | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8761 |
610
+ | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8949 |
611
+ | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 9227 |
612
612
  | `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
613
613
  | `React: common routing/data exports` | 2761 |
614
614
  | `Preact: common routing/data exports` | 3046 |
@@ -748,6 +748,33 @@ import { mountApplication } from "@kasperrt/smolter/preact/client";
748
748
  await mountApplication(routes, root, { deployment });
749
749
  ```
750
750
 
751
- Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. The Node/Express adapter streams the response; hydration starts after the document stream finishes. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
751
+ Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. 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. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
752
752
 
753
753
  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
+
755
+
756
+ ### Document hydration and injected tags
757
+
758
+ Preact hydrates after document completion by default. For an interactive shell while optional data is pending:
759
+
760
+ ```ts
761
+ return renderApplication(request, routes, root, entry, {
762
+ earlyHydration: true,
763
+ nonce,
764
+ });
765
+ ```
766
+
767
+ Keep optional nested promises inside `Suspense` and `Await`. The browser entry runs asynchronously; each deferred result settles without rerunning the initial loader. Required top-level loader data still blocks the shell. Use production streaming hosting; Vite development HTML transformation buffers. Measure a successful interaction rather than treating TTFB or DOMContentLoaded as hydration completion.
768
+
769
+ Infrastructure that injects direct children of `<head>` can mark those external nodes with `data-smolter-preserve`. Smolter excludes them from positional hydration and restores the same nodes afterward. Do not also render them in the application layout. Unmarked HTML rewriting, body injection and changes to the hydration payload are not supported.
770
+
771
+ ```html
772
+ <script data-smolter-preserve src="/infrastructure-instrumentation.js"></script>
773
+ ```
774
+
775
+ The generated route asset manifest follows static imports for each route and leaves optional dynamic imports lazy. Serve the matched route's preload hints with the correct asset base; do not preload every route to reduce a waterfall. Chunk counts and total emitted bytes alone do not establish initial-load latency.
776
+
777
+
778
+ ### Matched-route asset hints
779
+
780
+ 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.
@@ -8,6 +8,7 @@ import { RouterApplication, prepareRoutes } from "./router.js";
8
8
  import { deserialize } from "../client/deserialize.js";
9
9
  import { recoverDeployment, watchDeployment } from "../client/deployment.js";
10
10
  import { serverRoute } from "../client/index.js";
11
+ import { readHydration } from "./deferred-hydration.js";
11
12
  import { flushSync } from "preact/compat";
12
13
  import { jsx } from "preact/jsx-runtime";
13
14
  import { hydrate, render } from "preact";
@@ -23,19 +24,19 @@ async function mountApplication(routes, rootModule, options = false) {
23
24
  });
24
25
  routes = prepareRoutes(routes);
25
26
  const element = document.getElementById("smolter-data");
26
- const hydration = element?.textContent ? JSON.parse(element.textContent, (_key, value) => {
27
- if (!value || typeof value !== "object" || !("__smolterRejected" in value)) return value;
28
- const error = Object.assign(new Error(value.__smolterRejected.message), value.__smolterRejected);
29
- const rejected = Promise.reject(error);
30
- rejected.catch(() => {});
31
- return rejected;
32
- }) : void 0;
27
+ const initial = element?.textContent ? readHydration(element.textContent) : void 0;
28
+ const hydration = initial?.hydration;
33
29
  if (hydration?.snapshot.error) hydration.snapshot.error = Object.assign(new Error(hydration.snapshot.error.message), hydration.snapshot.error);
34
30
  const router = createDataRouter(routes, {
35
31
  browser: !hash,
36
32
  url: hash ? new URL(location.hash.slice(1) || "/", location.origin).href : location.href,
37
33
  ...hydration && { hydrationData: hydration.snapshot }
38
34
  });
35
+ const dispose = router.dispose;
36
+ router.dispose = () => {
37
+ initial?.dispose();
38
+ dispose();
39
+ };
39
40
  watchDeployment(router, deployment);
40
41
  if (hash) connectHashHistory(router);
41
42
  retainRoute(router, flushSync);
@@ -50,10 +51,18 @@ async function mountApplication(routes, rootModule, options = false) {
50
51
  rootError: hydration?.rootError
51
52
  };
52
53
  if (hydration) {
53
- hydrate(/* @__PURE__ */ jsx(RouterApplication, {
54
+ const preserved = Array.from(document.querySelectorAll("head > [data-smolter-preserve], body > [data-smolter-stream]"), (node) => ({
55
+ node,
56
+ next: node.nextSibling,
57
+ parent: node.parentNode
58
+ }));
59
+ for (const { node } of preserved) node.remove();
60
+ await Promise.resolve().then(() => hydrate(/* @__PURE__ */ jsx(RouterApplication, {
54
61
  ...props,
55
62
  document: true
56
- }), document);
63
+ }), document)).finally(() => {
64
+ for (const { node, next, parent } of preserved.reverse()) parent.insertBefore(node, next?.parentNode === parent ? next : null);
65
+ });
57
66
  return router;
58
67
  }
59
68
  const target = document.getElementById("root");
@@ -0,0 +1,12 @@
1
+ type Settlement = [number, unknown, string | null];
2
+ declare global {
3
+ interface Window {
4
+ __smolterDeferred?: Settlement[];
5
+ }
6
+ }
7
+ /** Restore promise identity before hydrating Suspense boundaries. */
8
+ export declare function readHydration(json: string): {
9
+ hydration: any;
10
+ dispose: () => void;
11
+ };
12
+ export {};
@@ -0,0 +1,64 @@
1
+ //#region .generated/preact/deferred-hydration.ts
2
+ /** Restore promise identity before hydrating Suspense boundaries. */
3
+ function readHydration(json) {
4
+ const pending = /* @__PURE__ */ new Map();
5
+ const queue = window.__smolterDeferred ??= [];
6
+ function revive(_key, value) {
7
+ if (!value || typeof value !== "object") return value;
8
+ if ("__smolterUndefined" in value) return void 0;
9
+ if ("__smolterPending" in value && typeof value.__smolterPending === "number") {
10
+ const id = value.__smolterPending;
11
+ const existing = pending.get(id);
12
+ if (existing) return existing.promise;
13
+ let resolve;
14
+ let reject;
15
+ const promise = new Promise((accept, fail) => {
16
+ resolve = accept;
17
+ reject = fail;
18
+ });
19
+ promise.catch(() => {});
20
+ pending.set(id, {
21
+ promise,
22
+ resolve,
23
+ reject
24
+ });
25
+ return promise;
26
+ }
27
+ if ("__smolterRejected" in value) {
28
+ const failure = value.__smolterRejected;
29
+ const rejected = Promise.reject(Object.assign(new Error(failure.message), failure));
30
+ rejected.catch(() => {});
31
+ return rejected;
32
+ }
33
+ return value;
34
+ }
35
+ const hydration = JSON.parse(json, revive);
36
+ const dispose = () => {
37
+ window.removeEventListener("DOMContentLoaded", dispose);
38
+ for (const target of pending.values()) target.reject(/* @__PURE__ */ new Error("Hydration stream closed"));
39
+ pending.clear();
40
+ queue.push = () => 0;
41
+ };
42
+ const receive = (packet) => {
43
+ const [id, value, error] = packet;
44
+ if (id === -1) return dispose();
45
+ const target = pending.get(id);
46
+ if (!target) return;
47
+ if (error !== null) target.reject(new Error(error));
48
+ if (error === null) target.resolve(JSON.parse(JSON.stringify(value), revive));
49
+ };
50
+ const buffered = queue.splice(0);
51
+ queue.push = (...packets) => {
52
+ packets.forEach(receive);
53
+ return 0;
54
+ };
55
+ window.addEventListener("DOMContentLoaded", dispose, { once: true });
56
+ buffered.forEach(receive);
57
+ if (document.readyState !== "loading") dispose();
58
+ return {
59
+ hydration,
60
+ dispose
61
+ };
62
+ }
63
+ //#endregion
64
+ export { readHydration };
@@ -44,8 +44,9 @@ function Links() {
44
44
  return /* @__PURE__ */ jsx(Fragment$1, { children: rootModule?.links?.().map((props, index) => /* @__PURE__ */ jsx("link", { ...props }, index)) });
45
45
  }
46
46
  function Scripts({ nonce }) {
47
- const { root, rootError, router, entry, streaming } = useApplication();
47
+ const { root, rootError, router, entry, streaming, initialData } = useApplication();
48
48
  const [json] = useState(() => {
49
+ if (initialData !== void 0) return initialData;
49
50
  if (streaming) return "";
50
51
  const current = router.getSnapshot();
51
52
  const snapshot = {
@@ -75,6 +76,7 @@ function Scripts({ nonce }) {
75
76
  }),
76
77
  entry && /* @__PURE__ */ jsx("script", {
77
78
  type: "module",
79
+ async: initialData !== void 0,
78
80
  src: entry,
79
81
  ...nonce && { nonce }
80
82
  })
@@ -6,6 +6,7 @@ export interface ApplicationContext {
6
6
  root: unknown;
7
7
  rootError?: boolean;
8
8
  streaming?: boolean;
9
+ initialData?: string;
9
10
  rootModule?: RootModule;
10
11
  hydration?: Hydration;
11
12
  entry?: string;
@@ -2,6 +2,7 @@
2
2
  import type { AppRoute, RootModule } from "../preact/route-types.ts";
3
3
  export interface RenderApplicationOptions {
4
4
  nonce?: string;
5
+ earlyHydration?: boolean;
5
6
  }
6
7
  export declare function renderApplication(request: Request, routes: AppRoute[], rootModule: RootModule, entry: string, options?: RenderApplicationOptions): Promise<Response>;
7
8
  export declare function dispatchRoute(request: Request, routes: AppRoute[], document?: boolean): Promise<Response>;
@@ -5,6 +5,8 @@ import { normalizeResult, resolveData } from "../core/responses.js";
5
5
  import { RouterApplication, prepareRoutes } from "../preact/router.js";
6
6
  import { privateResponse } from "./private-response.js";
7
7
  import { loadRouter } from "./index.js";
8
+ import { deferredHydration } from "./deferred-hydration.js";
9
+ import { progressiveDocument } from "./progressive-document.js";
8
10
  import { resolveHydration, streamDocument } from "./stream-document.js";
9
11
  import { jsx } from "preact/jsx-runtime";
10
12
  import { renderToReadableStream } from "preact-render-to-string/stream";
@@ -68,17 +70,7 @@ async function renderApplication(request, routes, rootModule, entry, options = {
68
70
  router = createDataRouter(router.routes, { hydrationData });
69
71
  }
70
72
  const snapshot = router.getSnapshot();
71
- const stream = renderToReadableStream(/* @__PURE__ */ jsx(RouterApplication, {
72
- router,
73
- routes,
74
- root,
75
- rootModule,
76
- entry,
77
- rootError: Boolean(rootError),
78
- streaming: true,
79
- document: true
80
- }), options);
81
- const hydration = resolveHydration({
73
+ const hydrationData = {
82
74
  root,
83
75
  snapshot: {
84
76
  ...snapshot,
@@ -89,15 +81,30 @@ async function renderApplication(request, routes, rootModule, entry, options = {
89
81
  }
90
82
  },
91
83
  ...rootError && { rootError: true }
92
- });
84
+ };
85
+ const deferred = options.earlyHydration ? deferredHydration(hydrationData, options.nonce) : void 0;
86
+ const stream = renderToReadableStream(/* @__PURE__ */ jsx(RouterApplication, {
87
+ router,
88
+ routes,
89
+ root,
90
+ rootModule,
91
+ entry,
92
+ rootError: Boolean(rootError),
93
+ streaming: true,
94
+ initialData: deferred?.initial,
95
+ document: true
96
+ }), options);
93
97
  stream.allReady.catch(() => {});
94
98
  const headers = new Headers(actionResponse?.headers);
95
99
  headers.delete("Content-Length");
96
100
  headers.delete("Content-Encoding");
97
101
  headers.set("Content-Type", "text/html; charset=utf-8");
98
102
  headers.set("Cache-Control", "no-store");
99
- return new Response(streamDocument(stream, {
100
- hydration,
103
+ return new Response(deferred ? progressiveDocument(stream, deferred.stream, {
104
+ signal: request.signal,
105
+ dispose: () => router.dispose()
106
+ }) : streamDocument(stream, {
107
+ hydration: resolveHydration(hydrationData),
101
108
  signal: request.signal,
102
109
  nonce: options.nonce,
103
110
  dispose: () => router.dispose()
@@ -1,5 +1,6 @@
1
1
  export interface RouteAssets {
2
2
  path: string;
3
3
  files: string[];
4
+ styles?: string[];
4
5
  }
5
6
  export declare function routePreloads(routes: RouteAssets[], pathname: string, base: string): string;
@@ -1,7 +1,9 @@
1
1
  import { matchRoutes } from "../core/match.js";
2
2
  //#region .generated/server/assets.ts
3
3
  function routePreloads(routes, pathname, base) {
4
- return ((matchRoutes(routes, pathname)?.route)?.files ?? []).map((file) => `<${base.replace(/\/$/, "")}/${file}>; rel=modulepreload`).join(", ");
4
+ const route = matchRoutes(routes, pathname)?.route;
5
+ const prefix = base.replace(/\/$/, "");
6
+ return [...(route?.styles ?? []).map((file) => `<${prefix}/${file}>; rel=preload; as=style; crossorigin`), ...(route?.files ?? []).map((file) => `<${prefix}/${file}>; rel=modulepreload`)].join(", ");
5
7
  }
6
8
  //#endregion
7
9
  export { routePreloads };
@@ -0,0 +1,5 @@
1
+ /** Encode deferred values without delaying the initial document snapshot. */
2
+ export declare function deferredHydration(value: unknown, nonce?: string): {
3
+ initial: string;
4
+ stream: ReadableStream<Uint8Array<ArrayBufferLike>>;
5
+ };
@@ -0,0 +1,59 @@
1
+ import { serializeData } from "../core/serialize.js";
2
+ //#region .generated/server/deferred-hydration.ts
3
+ /** Encode deferred values without delaying the initial document snapshot. */
4
+ function deferredHydration(value, nonce) {
5
+ const promises = /* @__PURE__ */ new Map();
6
+ let pending = 0;
7
+ let closed = false;
8
+ let controller;
9
+ const encoder = new TextEncoder();
10
+ const attribute = nonce ? ` nonce="${nonce.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;")}"` : "";
11
+ const stream = new ReadableStream({
12
+ start(value) {
13
+ controller = value;
14
+ },
15
+ cancel() {
16
+ closed = true;
17
+ }
18
+ });
19
+ function encode(value) {
20
+ if (value instanceof Promise) {
21
+ const existing = promises.get(value);
22
+ if (existing !== void 0) return { __smolterPending: existing };
23
+ const id = promises.size;
24
+ promises.set(value, id);
25
+ pending++;
26
+ value.then((result) => settle(id, encode(result))).catch((error) => settle(id, null, error instanceof Error ? error.message : String(error)));
27
+ return { __smolterPending: id };
28
+ }
29
+ if (Array.isArray(value)) return value.map(encode);
30
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) return Object.fromEntries(Object.entries(value).map(([key, field]) => [key, encode(field)]));
31
+ return value;
32
+ }
33
+ function settle(id, value, error) {
34
+ if (closed) return;
35
+ const packet = serializeData([
36
+ id,
37
+ value === void 0 ? { __smolterUndefined: true } : value,
38
+ error
39
+ ]);
40
+ controller.enqueue(encoder.encode(`<script${attribute}>(window.__smolterDeferred??=[]).push(${packet});document.currentScript.remove()<\/script>`));
41
+ pending--;
42
+ if (!pending) {
43
+ closed = true;
44
+ controller.enqueue(encoder.encode(`<script${attribute}>(window.__smolterDeferred??=[]).push([-1,null,null]);document.currentScript.remove()<\/script>`));
45
+ controller.close();
46
+ }
47
+ }
48
+ const initial = serializeData(encode(value));
49
+ if (!pending) {
50
+ closed = true;
51
+ controller.close();
52
+ }
53
+ return {
54
+ initial,
55
+ stream
56
+ };
57
+ }
58
+ //#endregion
59
+ export { deferredHydration };
@@ -0,0 +1,7 @@
1
+ interface ProgressiveDocumentOptions {
2
+ signal: AbortSignal;
3
+ dispose: () => void;
4
+ }
5
+ /** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
6
+ export declare function progressiveDocument(source: ReadableStream<Uint8Array>, settlements: ReadableStream<Uint8Array>, options: ProgressiveDocumentOptions): ReadableStream<Uint8Array>;
7
+ export {};
@@ -0,0 +1,81 @@
1
+ //#region .generated/server/progressive-document.ts
2
+ /** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
3
+ function progressiveDocument(source, settlements, options) {
4
+ const html = source.getReader();
5
+ const data = settlements.getReader();
6
+ const encoder = new TextEncoder();
7
+ const decoder = new TextDecoder();
8
+ let stopped = false;
9
+ let cancelled = false;
10
+ let first = true;
11
+ let suffix = "";
12
+ const readHTML = () => html.read().then((result) => ({
13
+ ...result,
14
+ html: true
15
+ }), (error) => ({ error }));
16
+ const readData = () => data.read().then((result) => ({
17
+ ...result,
18
+ html: false
19
+ }), (error) => ({ error }));
20
+ let nextHTML;
21
+ let nextData;
22
+ const stop = () => {
23
+ if (stopped) return;
24
+ stopped = true;
25
+ options.signal.removeEventListener("abort", stop);
26
+ options.dispose();
27
+ html.cancel().catch(() => {});
28
+ data.cancel().catch(() => {});
29
+ };
30
+ options.signal.addEventListener("abort", stop, { once: true });
31
+ if (options.signal.aborted) stop();
32
+ return new ReadableStream({
33
+ async pull(controller) {
34
+ const work = async () => {
35
+ if (first && !stopped) nextHTML = readHTML();
36
+ while (!stopped && (nextHTML || nextData)) {
37
+ const reads = [nextHTML, nextData].filter((value) => value !== void 0);
38
+ const result = await Promise.race(reads);
39
+ if (stopped) break;
40
+ if ("error" in result) return Promise.reject(result.error);
41
+ if (result.html) nextHTML = result.done ? void 0 : readHTML();
42
+ if (!result.html) nextData = result.done ? void 0 : readData();
43
+ if (first) nextData = readData();
44
+ if (result.done) {
45
+ first = false;
46
+ continue;
47
+ }
48
+ if (!result.html) {
49
+ controller.enqueue(result.value);
50
+ return;
51
+ }
52
+ let chunk = decoder.decode(result.value, { stream: true });
53
+ if (chunk === "<div hidden>") chunk = "<div hidden data-smolter-stream>";
54
+ if (first && !/^<!doctype/i.test(chunk)) chunk = `<!doctype html>${chunk}`;
55
+ first = false;
56
+ const closing = chunk.lastIndexOf("</body>");
57
+ if (closing !== -1) {
58
+ suffix = chunk.slice(closing);
59
+ chunk = chunk.slice(0, closing);
60
+ }
61
+ controller.enqueue(encoder.encode(chunk));
62
+ return;
63
+ }
64
+ if (cancelled) return;
65
+ if (!stopped) controller.enqueue(encoder.encode(suffix));
66
+ stop();
67
+ controller.close();
68
+ };
69
+ await work().catch((error) => {
70
+ stop();
71
+ controller.error(error);
72
+ });
73
+ },
74
+ cancel() {
75
+ cancelled = true;
76
+ stop();
77
+ }
78
+ });
79
+ }
80
+ //#endregion
81
+ export { progressiveDocument };
@@ -20,17 +20,21 @@ function lazyFiles(directory, dataEndpoint = "/_smolter") {
20
20
  const path = modules.get(chunk.facadeModuleId);
21
21
  if (!path) continue;
22
22
  const files = /* @__PURE__ */ new Set();
23
+ const styles = /* @__PURE__ */ new Set();
23
24
  function visit(file) {
24
25
  if (files.has(file)) return;
25
- files.add(file);
26
26
  const dependency = bundle[file];
27
27
  if (dependency?.type !== "chunk") return;
28
+ files.add(file);
29
+ const metadata = dependency.viteMetadata;
30
+ for (const stylesheet of metadata?.importedCss ?? []) if (bundle[stylesheet]?.type === "asset") styles.add(stylesheet);
28
31
  for (const imported of dependency.imports) visit(imported);
29
32
  }
30
33
  visit(chunk.fileName);
31
34
  routes.push({
32
35
  path,
33
- files: [...files]
36
+ files: [...files],
37
+ styles: [...styles]
34
38
  });
35
39
  }
36
40
  const paths = [...modules.values()];
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kasperrt/smolter",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "exports": {
5
5
  "./react": "./.generated/react/index.ts",
6
6
  "./preact": "./.generated/preact/index.ts",
package/package.json CHANGED
@@ -56,7 +56,7 @@
56
56
  "wouter": "3.13.0",
57
57
  "wouter-preact": "3.13.0"
58
58
  },
59
- "version": "0.3.4",
59
+ "version": "0.4.0",
60
60
  "description": "A tiny typed data router for React and Preact.",
61
61
  "sideEffects": false,
62
62
  "license": "MIT",