@kasperrt/smolter 0.3.5 → 0.4.1

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.
@@ -1,4 +1,4 @@
1
- import type { NavigationRouter } from "../core/index.ts";
1
+ import type { NavigationRouter, Router } from "../core/index.ts";
2
2
  import { deploymentCheck } from "./deployment-check.ts";
3
3
 
4
4
  /** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
@@ -11,7 +11,7 @@ export interface DeploymentOptions {
11
11
  reload?: (destination: URL) => void;
12
12
  }
13
13
 
14
- /** Check for deployment changes before navigation. Returns a cleanup function. */
14
+ /** Check for deployment changes before navigation. Install before creating fetchers. Returns a cleanup function. */
15
15
  export function watchDeployment(
16
16
  router: NavigationRouter,
17
17
  options: DeploymentOptions | null,
@@ -26,7 +26,31 @@ export function watchDeployment(
26
26
  let sequence = 0;
27
27
  let stopped = false;
28
28
  let replay = false;
29
+ let mutations = 0;
30
+ const dataRouter = router as NavigationRouter &
31
+ Partial<Pick<Router, "createFetcher">>;
32
+ const createFetcher = dataRouter.createFetcher;
33
+ const guardedFetcher =
34
+ createFetcher &&
35
+ (() => {
36
+ const fetcher = createFetcher();
37
+ const submit = fetcher.submit;
38
+
39
+ fetcher.submit = (...args) => {
40
+ sequence++;
41
+ mutations++;
42
+
43
+ return submit(...args).finally(() => {
44
+ mutations--;
45
+ });
46
+ };
47
+
48
+ return fetcher;
49
+ });
50
+
51
+ if (guardedFetcher) dataRouter.createFetcher = guardedFetcher;
29
52
  const valid = (current: number) => current === sequence && !stopped;
53
+ const canReload = (current: number) => valid(current) && mutations === 0;
30
54
  const unsubscribe = router.subscribe(() => {
31
55
  if (read().state === "submitting") sequence++;
32
56
  });
@@ -42,7 +66,7 @@ export function watchDeployment(
42
66
  target.search !== currentURL.search) &&
43
67
  (await changed())
44
68
  ) {
45
- if (!valid(current) || (await reload(target, () => valid(current))))
69
+ if (!valid(current) || (await reload(target, () => canReload(current))))
46
70
  return;
47
71
  }
48
72
 
@@ -65,7 +89,7 @@ export function watchDeployment(
65
89
  event.stopImmediatePropagation();
66
90
  void changed().then(async (updated) => {
67
91
  if (!valid(current)) return;
68
- if (updated && (await reload(target, () => valid(current)))) return;
92
+ if (updated && (await reload(target, () => canReload(current)))) return;
69
93
  if (!valid(current)) return;
70
94
 
71
95
  replay = true;
@@ -88,7 +112,7 @@ export function watchDeployment(
88
112
  return reload(
89
113
  target,
90
114
  () =>
91
- valid(current) &&
115
+ canReload(current) &&
92
116
  read().state !== "submitting" &&
93
117
  read().url === target.href,
94
118
  );
@@ -103,6 +127,8 @@ export function watchDeployment(
103
127
  window.removeEventListener("popstate", pop, true);
104
128
  window.removeEventListener("vite:preloadError", preloadError);
105
129
 
130
+ if (dataRouter.createFetcher === guardedFetcher)
131
+ dataRouter.createFetcher = createFetcher;
106
132
  if (router.navigate === guarded) router.navigate = navigate;
107
133
  if (router.dispose === guardedDispose) router.dispose = dispose;
108
134
  }
@@ -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
 
@@ -98,19 +89,18 @@ export async function mountApplication(
98
89
  if (hydration) {
99
90
  // Explicitly unmanaged head nodes must not participate in positional hydration.
100
91
  const preserved = Array.from(
101
- document.head.querySelectorAll(":scope > [data-smolter-preserve]"),
102
- (node) => ({ node, next: node.nextSibling }),
92
+ document.querySelectorAll(
93
+ "head > [data-smolter-preserve], body > [data-smolter-stream]",
94
+ ),
95
+ (node) => ({ node, next: node.nextSibling, parent: node.parentNode! }),
103
96
  );
104
97
  for (const { node } of preserved) node.remove();
105
98
 
106
99
  await Promise.resolve()
107
100
  .then(() => hydrate(<RouterApplication {...props} document />, document))
108
101
  .finally(() => {
109
- for (const { node, next } of preserved.reverse())
110
- document.head.insertBefore(
111
- node,
112
- next?.parentNode === document.head ? next : null,
113
- );
102
+ for (const { node, next, parent } of preserved.reverse())
103
+ parent.insertBefore(node, next?.parentNode === parent ? next : null);
114
104
  });
115
105
 
116
106
  return router;
@@ -0,0 +1,85 @@
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
+ export interface DeferredWindow extends Window {
10
+ __smolterDeferred?: Settlement[];
11
+ }
12
+
13
+ /** Restore promise identity before hydrating Suspense boundaries. */
14
+ export function readHydration(json: string) {
15
+ const pending = new Map<number, PendingValue>();
16
+ const queue = ((window as DeferredWindow).__smolterDeferred ??= []);
17
+
18
+ function revive(_key: string, value: unknown): unknown {
19
+ if (!value || typeof value !== "object") return value;
20
+ if ("__smolterUndefined" in value) return undefined;
21
+
22
+ if (
23
+ "__smolterPending" in value &&
24
+ typeof value.__smolterPending === "number"
25
+ ) {
26
+ const id = value.__smolterPending;
27
+ const existing = pending.get(id);
28
+ if (existing) return existing.promise;
29
+
30
+ let resolve!: PendingValue["resolve"];
31
+ let reject!: PendingValue["reject"];
32
+ const promise = new Promise((accept, fail) => {
33
+ resolve = accept;
34
+ reject = fail;
35
+ });
36
+ void promise.catch(() => {});
37
+ pending.set(id, { promise, resolve, reject });
38
+
39
+ return promise;
40
+ }
41
+
42
+ if ("__smolterRejected" in value) {
43
+ const failure = value.__smolterRejected as { message: string };
44
+ const rejected = Promise.reject(
45
+ Object.assign(new Error(failure.message), failure),
46
+ );
47
+ void rejected.catch(() => {});
48
+
49
+ return rejected;
50
+ }
51
+
52
+ return value;
53
+ }
54
+
55
+ const hydration = JSON.parse(json, revive);
56
+ const dispose = () => {
57
+ window.removeEventListener("DOMContentLoaded", dispose);
58
+ for (const target of pending.values())
59
+ target.reject(new Error("Hydration stream closed"));
60
+ pending.clear();
61
+ queue.push = () => 0;
62
+ };
63
+ const receive = (packet: Settlement) => {
64
+ const [id, value, error] = packet;
65
+ if (id === -1) return dispose();
66
+
67
+ const target = pending.get(id);
68
+ if (!target) return;
69
+
70
+ if (error !== null) target.reject(new Error(error));
71
+ if (error === null)
72
+ target.resolve(JSON.parse(JSON.stringify(value), revive));
73
+ };
74
+ const buffered = queue.splice(0);
75
+ queue.push = (...packets) => {
76
+ packets.forEach(receive);
77
+ return 0;
78
+ };
79
+
80
+ window.addEventListener("DOMContentLoaded", dispose, { once: true });
81
+ buffered.forEach(receive);
82
+ if (document.readyState !== "loading") dispose();
83
+
84
+ return { hydration, dispose };
85
+ }
@@ -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,6 +35,7 @@ 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;
@@ -41,11 +46,16 @@ export function lazyFiles(
41
46
 
42
47
  files.add(file);
43
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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - 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.
6
+ - Add regressions for two fetcher mutations completing before an older manifest check, overlapping pending submissions, unused recovery markers, later recovery, and watcher cleanup. These deterministic tests do not establish the complete streaming/offline browser matrix.
7
+ - Clarify that root stylesheet URL imports rely on rendered stylesheet links and are outside route CSS discovery. Empty route styles arrays are expected in that setup; explicit host preload hints should be justified by measurements.
8
+
9
+ ## 0.4.0
10
+
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - Update SSR and loading documentation, including removal of stale claims that the Express adapter buffers responses.
16
+
3
17
  ## 0.3.5
4
18
 
5
19
  - Preserve explicitly unmanaged head elements marked data-smolter-preserve during Preact document hydration, without matching them to application-owned nodes.
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.5%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.5%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.5%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.5%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.1%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.1%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.1%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.1%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.**
@@ -605,10 +605,10 @@ a claim of identical features.
605
605
  | `Preact: routing-only named exports` | 1405 |
606
606
  | `@kasperrt/smolter/react` | 2948 |
607
607
  | `@kasperrt/smolter/preact` | 4842 |
608
- | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4104 |
609
- | `@kasperrt/smolter/preact + server transport` | 5950 |
610
- | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8607 |
611
- | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8890 |
608
+ | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4186 |
609
+ | `@kasperrt/smolter/preact + server transport` | 6033 |
610
+ | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 9034 |
611
+ | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 9314 |
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,14 +748,23 @@ 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
754
 
755
755
 
756
756
  ### Document hydration and injected tags
757
757
 
758
- Preact hydrates the complete document after the response finishes. A streamed shell is visible before optional data settles, but event handlers are not active yet. Measure a successful interaction rather than treating TTFB or DOMContentLoaded as hydration completion.
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.
759
768
 
760
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.
761
770
 
@@ -764,3 +773,10 @@ Infrastructure that injects direct children of `<head>` can mark those external
764
773
  ```
765
774
 
766
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.
781
+
782
+ 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.
@@ -8,7 +8,7 @@ export interface DeploymentOptions {
8
8
  /** Override document navigation, for example to ask about unsaved changes. */
9
9
  reload?: (destination: URL) => void;
10
10
  }
11
- /** Check for deployment changes before navigation. Returns a cleanup function. */
11
+ /** Check for deployment changes before navigation. Install before creating fetchers. Returns a cleanup function. */
12
12
  export declare function watchDeployment(router: NavigationRouter, options: DeploymentOptions | null): () => void;
13
13
  /** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
14
14
  export declare function recoverDeployment(options: DeploymentOptions | null, destination?: URL): Promise<boolean>;
@@ -1,6 +1,6 @@
1
1
  import { deploymentCheck } from "./deployment-check.js";
2
2
  //#region .generated/client/deployment.ts
3
- /** Check for deployment changes before navigation. Returns a cleanup function. */
3
+ /** Check for deployment changes before navigation. Install before creating fetchers. Returns a cleanup function. */
4
4
  function watchDeployment(router, options) {
5
5
  if (!options || typeof window === "undefined") return () => {};
6
6
  const check = deploymentCheck(options);
@@ -11,7 +11,24 @@ function watchDeployment(router, options) {
11
11
  let sequence = 0;
12
12
  let stopped = false;
13
13
  let replay = false;
14
+ let mutations = 0;
15
+ const dataRouter = router;
16
+ const createFetcher = dataRouter.createFetcher;
17
+ const guardedFetcher = createFetcher && (() => {
18
+ const fetcher = createFetcher();
19
+ const submit = fetcher.submit;
20
+ fetcher.submit = (...args) => {
21
+ sequence++;
22
+ mutations++;
23
+ return submit(...args).finally(() => {
24
+ mutations--;
25
+ });
26
+ };
27
+ return fetcher;
28
+ });
29
+ if (guardedFetcher) dataRouter.createFetcher = guardedFetcher;
14
30
  const valid = (current) => current === sequence && !stopped;
31
+ const canReload = (current) => valid(current) && mutations === 0;
15
32
  const unsubscribe = router.subscribe(() => {
16
33
  if (read().state === "submitting") sequence++;
17
34
  });
@@ -20,7 +37,7 @@ function watchDeployment(router, options) {
20
37
  const currentURL = new URL(router.getSnapshot().url);
21
38
  const current = ++sequence;
22
39
  if (target.origin === location.origin && (target.pathname !== currentURL.pathname || target.search !== currentURL.search) && await changed()) {
23
- if (!valid(current) || await reload(target, () => valid(current))) return;
40
+ if (!valid(current) || await reload(target, () => canReload(current))) return;
24
41
  }
25
42
  if (current === sequence && !stopped) await navigate(href, operation);
26
43
  };
@@ -33,7 +50,7 @@ function watchDeployment(router, options) {
33
50
  event.stopImmediatePropagation();
34
51
  changed().then(async (updated) => {
35
52
  if (!valid(current)) return;
36
- if (updated && await reload(target, () => valid(current))) return;
53
+ if (updated && await reload(target, () => canReload(current))) return;
37
54
  if (!valid(current)) return;
38
55
  replay = true;
39
56
  window.dispatchEvent(new PopStateEvent("popstate", { state: event.state }));
@@ -45,7 +62,7 @@ function watchDeployment(router, options) {
45
62
  const current = sequence;
46
63
  const target = new URL(read().url);
47
64
  changed().then((updated) => {
48
- if (updated) return reload(target, () => valid(current) && read().state !== "submitting" && read().url === target.href);
65
+ if (updated) return reload(target, () => canReload(current) && read().state !== "submitting" && read().url === target.href);
49
66
  });
50
67
  };
51
68
  function cleanup() {
@@ -55,6 +72,7 @@ function watchDeployment(router, options) {
55
72
  unsubscribe();
56
73
  window.removeEventListener("popstate", pop, true);
57
74
  window.removeEventListener("vite:preloadError", preloadError);
75
+ if (dataRouter.createFetcher === guardedFetcher) dataRouter.createFetcher = createFetcher;
58
76
  if (router.navigate === guarded) router.navigate = navigate;
59
77
  if (router.dispose === guardedDispose) router.dispose = dispose;
60
78
  }
@@ -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,16 +51,17 @@ async function mountApplication(routes, rootModule, options = false) {
50
51
  rootError: hydration?.rootError
51
52
  };
52
53
  if (hydration) {
53
- const preserved = Array.from(document.head.querySelectorAll(":scope > [data-smolter-preserve]"), (node) => ({
54
+ const preserved = Array.from(document.querySelectorAll("head > [data-smolter-preserve], body > [data-smolter-stream]"), (node) => ({
54
55
  node,
55
- next: node.nextSibling
56
+ next: node.nextSibling,
57
+ parent: node.parentNode
56
58
  }));
57
59
  for (const { node } of preserved) node.remove();
58
60
  await Promise.resolve().then(() => hydrate(/* @__PURE__ */ jsx(RouterApplication, {
59
61
  ...props,
60
62
  document: true
61
63
  }), document)).finally(() => {
62
- for (const { node, next } of preserved.reverse()) document.head.insertBefore(node, next?.parentNode === document.head ? next : null);
64
+ for (const { node, next, parent } of preserved.reverse()) parent.insertBefore(node, next?.parentNode === parent ? next : null);
63
65
  });
64
66
  return router;
65
67
  }
@@ -0,0 +1,10 @@
1
+ type Settlement = [number, unknown, string | null];
2
+ export interface DeferredWindow extends Window {
3
+ __smolterDeferred?: Settlement[];
4
+ }
5
+ /** Restore promise identity before hydrating Suspense boundaries. */
6
+ export declare function readHydration(json: string): {
7
+ hydration: any;
8
+ dispose: () => void;
9
+ };
10
+ 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
26
  const dependency = bundle[file];
26
27
  if (dependency?.type !== "chunk") return;
27
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.5",
3
+ "version": "0.4.1",
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.5",
59
+ "version": "0.4.1",
60
60
  "description": "A tiny typed data router for React and Preact.",
61
61
  "sideEffects": false,
62
62
  "license": "MIT",