@kasperrt/smolter 0.4.0 → 0.4.2

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