@kasperrt/smolter 0.3.3 → 0.3.5

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.
@@ -36,27 +36,24 @@ export function deploymentCheck(options: DeploymentOptions) {
36
36
  return pending;
37
37
  }
38
38
 
39
- async function reload(target: URL) {
40
- // If storage is unavailable, do not risk reloading the same cached document forever.
41
- const allowed = await Promise.resolve()
39
+ function reload(target: URL, current: () => boolean = () => true) {
40
+ // Validate and claim recovery in the same task: stale checks must not consume
41
+ // the marker or reload after a newer navigation or mutation has started.
42
+ return Promise.resolve()
42
43
  .then(() => {
43
- if (sessionStorage.getItem(key)) return false;
44
+ if (stopped || !current() || sessionStorage.getItem(key)) return false;
44
45
 
45
46
  sessionStorage.setItem(key, "1");
46
47
 
48
+ if (options.reload) options.reload(target);
49
+ if (!options.reload) {
50
+ if (target.href === location.href) location.reload();
51
+ if (target.href !== location.href) location.assign(target.href);
52
+ }
53
+
47
54
  return true;
48
55
  })
49
56
  .catch(() => false);
50
-
51
- if (allowed && !stopped) {
52
- if (options?.reload) options.reload(target);
53
- if (!options?.reload) {
54
- if (target.href === location.href) location.reload();
55
- if (target.href !== location.href) location.assign(target.href);
56
- }
57
- }
58
-
59
- return allowed && !stopped;
60
57
  }
61
58
 
62
59
  return {
@@ -20,18 +20,21 @@ export function watchDeployment(
20
20
 
21
21
  const check = deploymentCheck(options);
22
22
  const { changed, reload } = check;
23
+ const read = router.getSnapshot;
23
24
  const navigate = router.navigate;
24
25
  const dispose = router.dispose;
25
26
  let sequence = 0;
26
27
  let stopped = false;
27
28
  let replay = false;
28
- let destination = new URL(location.href);
29
+ const valid = (current: number) => current === sequence && !stopped;
30
+ const unsubscribe = router.subscribe(() => {
31
+ if (read().state === "submitting") sequence++;
32
+ });
29
33
 
30
34
  const guarded: NavigationRouter["navigate"] = async (href, operation) => {
31
35
  const target = new URL(href, router.getSnapshot().url);
32
36
  const currentURL = new URL(router.getSnapshot().url);
33
37
  const current = ++sequence;
34
- destination = target;
35
38
 
36
39
  if (
37
40
  target.origin === location.origin &&
@@ -39,7 +42,8 @@ export function watchDeployment(
39
42
  target.search !== currentURL.search) &&
40
43
  (await changed())
41
44
  ) {
42
- if (current !== sequence || stopped || (await reload(target))) return;
45
+ if (!valid(current) || (await reload(target, () => valid(current))))
46
+ return;
43
47
  }
44
48
 
45
49
  if (current === sequence && !stopped) await navigate(href, operation);
@@ -49,7 +53,8 @@ export function watchDeployment(
49
53
  if (replay) return;
50
54
 
51
55
  const target = new URL(location.href);
52
- const previous = new URL(router.getSnapshot().url);
56
+ const previous = new URL(read().url);
57
+ const current = ++sequence;
53
58
 
54
59
  if (
55
60
  target.pathname === previous.pathname &&
@@ -58,11 +63,10 @@ export function watchDeployment(
58
63
  return;
59
64
 
60
65
  event.stopImmediatePropagation();
61
- const current = ++sequence;
62
- destination = target;
63
66
  void changed().then(async (updated) => {
64
- if (stopped || current !== sequence) return;
65
- if (updated && (await reload(target))) return;
67
+ if (!valid(current)) return;
68
+ if (updated && (await reload(target, () => valid(current)))) return;
69
+ if (!valid(current)) return;
66
70
 
67
71
  replay = true;
68
72
  window.dispatchEvent(
@@ -75,12 +79,19 @@ export function watchDeployment(
75
79
  const preloadError = () => {
76
80
  // Intent prefetch is speculative: it must not reload the current page or
77
81
  // consume the recovery marker before a click records its destination.
78
- if (router.getSnapshot().state !== "loading") return;
82
+ if (read().state !== "loading") return;
79
83
 
80
84
  const current = sequence;
81
- const target = destination;
85
+ const target = new URL(read().url);
82
86
  void changed().then((updated) => {
83
- if (updated && current === sequence && !stopped) return reload(target);
87
+ if (updated)
88
+ return reload(
89
+ target,
90
+ () =>
91
+ valid(current) &&
92
+ read().state !== "submitting" &&
93
+ read().url === target.href,
94
+ );
84
95
  });
85
96
  };
86
97
 
@@ -88,6 +99,7 @@ export function watchDeployment(
88
99
  stopped = true;
89
100
  sequence++;
90
101
  check.dispose();
102
+ unsubscribe();
91
103
  window.removeEventListener("popstate", pop, true);
92
104
  window.removeEventListener("vite:preloadError", preloadError);
93
105
 
@@ -96,7 +96,22 @@ export async function mountApplication(
96
96
  };
97
97
 
98
98
  if (hydration) {
99
- hydrate(<RouterApplication {...props} document />, document);
99
+ // Explicitly unmanaged head nodes must not participate in positional hydration.
100
+ const preserved = Array.from(
101
+ document.head.querySelectorAll(":scope > [data-smolter-preserve]"),
102
+ (node) => ({ node, next: node.nextSibling }),
103
+ );
104
+ for (const { node } of preserved) node.remove();
105
+
106
+ await Promise.resolve()
107
+ .then(() => hydrate(<RouterApplication {...props} document />, document))
108
+ .finally(() => {
109
+ for (const { node, next } of preserved.reverse())
110
+ document.head.insertBefore(
111
+ node,
112
+ next?.parentNode === document.head ? next : null,
113
+ );
114
+ });
100
115
 
101
116
  return router;
102
117
  }
@@ -35,12 +35,12 @@ export function lazyFiles(
35
35
  function visit(file: string) {
36
36
  if (files.has(file)) return;
37
37
 
38
- files.add(file);
39
-
40
38
  const dependency = bundle[file];
41
39
 
42
40
  if (dependency?.type !== "chunk") return;
43
41
 
42
+ files.add(file);
43
+
44
44
  for (const imported of dependency.imports) visit(imported);
45
45
  }
46
46
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5
4
+
5
+ - Preserve explicitly unmanaged head elements marked data-smolter-preserve during Preact document hydration, without matching them to application-owned nodes.
6
+ - Test injected script/meta preservation, one-time script execution, styling and subsequent navigation.
7
+ - Add interaction-before/after-deferred-completion assertions and document that streaming does not provide progressive hydration.
8
+ - Fix route asset manifests incorrectly including external module specifiers as preload file URLs; retain transitive emitted chunks and leave optional dynamic imports lazy. No general request-count or latency improvement is claimed.
9
+
10
+ ## 0.3.4
11
+
12
+ - Cancel stale deployment checks when newer Back/Forward operations return to the current route, including hash changes.
13
+ - Recover missing action-redirect chunks at the committed destination, preserving query/hash and avoiding mutation replay.
14
+ - Invalidate pending chunk recovery when a mutation starts; validate recovery ownership before claiming the reload marker.
15
+ - Expand production-browser coverage for scoped application paths, separate CDN asset/manifest origins, overlapping navigation, Chromium offline/online recovery and history races. Manual and file-route HMR remain covered.
16
+
3
17
  ## 0.3.3
4
18
 
5
19
  - Ignore idle intent-prefetch chunk failures in deployment recovery so the next click retains its destination and reload marker.
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.3%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.3%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.3%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.3%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.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)
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` | 4044 |
609
- | `@kasperrt/smolter/preact + server transport` | 5883 |
610
- | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8415 |
611
- | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8701 |
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 |
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 |
@@ -751,3 +751,16 @@ await mountApplication(routes, root, { deployment });
751
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.
752
752
 
753
753
  Successful action redirects refresh registered root data without replaying the mutation. Loader arguments expose `reason` to distinguish initial loads, navigation, revalidation and fetcher reads without comparing browser URLs.
754
+
755
+
756
+ ### Document hydration and injected tags
757
+
758
+ Preact hydrates 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.
759
+
760
+ 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
+
762
+ ```html
763
+ <script data-smolter-preserve src="/infrastructure-instrumentation.js"></script>
764
+ ```
765
+
766
+ 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.
@@ -1,6 +1,6 @@
1
1
  import type { DeploymentOptions } from "./deployment.ts";
2
2
  export declare function deploymentCheck(options: DeploymentOptions): {
3
3
  changed: () => Promise<boolean>;
4
- reload: (target: URL) => Promise<boolean>;
4
+ reload: (target: URL, current?: () => boolean) => Promise<boolean>;
5
5
  dispose(): void;
6
6
  };
@@ -23,20 +23,17 @@ function deploymentCheck(options) {
23
23
  });
24
24
  return pending;
25
25
  }
26
- async function reload(target) {
27
- const allowed = await Promise.resolve().then(() => {
28
- if (sessionStorage.getItem(key)) return false;
26
+ function reload(target, current = () => true) {
27
+ return Promise.resolve().then(() => {
28
+ if (stopped || !current() || sessionStorage.getItem(key)) return false;
29
29
  sessionStorage.setItem(key, "1");
30
- return true;
31
- }).catch(() => false);
32
- if (allowed && !stopped) {
33
- if (options?.reload) options.reload(target);
34
- if (!options?.reload) {
30
+ if (options.reload) options.reload(target);
31
+ if (!options.reload) {
35
32
  if (target.href === location.href) location.reload();
36
33
  if (target.href !== location.href) location.assign(target.href);
37
34
  }
38
- }
39
- return allowed && !stopped;
35
+ return true;
36
+ }).catch(() => false);
40
37
  }
41
38
  return {
42
39
  changed,
@@ -5,50 +5,54 @@ function watchDeployment(router, options) {
5
5
  if (!options || typeof window === "undefined") return () => {};
6
6
  const check = deploymentCheck(options);
7
7
  const { changed, reload } = check;
8
+ const read = router.getSnapshot;
8
9
  const navigate = router.navigate;
9
10
  const dispose = router.dispose;
10
11
  let sequence = 0;
11
12
  let stopped = false;
12
13
  let replay = false;
13
- let destination = new URL(location.href);
14
+ const valid = (current) => current === sequence && !stopped;
15
+ const unsubscribe = router.subscribe(() => {
16
+ if (read().state === "submitting") sequence++;
17
+ });
14
18
  const guarded = async (href, operation) => {
15
19
  const target = new URL(href, router.getSnapshot().url);
16
20
  const currentURL = new URL(router.getSnapshot().url);
17
21
  const current = ++sequence;
18
- destination = target;
19
22
  if (target.origin === location.origin && (target.pathname !== currentURL.pathname || target.search !== currentURL.search) && await changed()) {
20
- if (current !== sequence || stopped || await reload(target)) return;
23
+ if (!valid(current) || await reload(target, () => valid(current))) return;
21
24
  }
22
25
  if (current === sequence && !stopped) await navigate(href, operation);
23
26
  };
24
27
  const pop = (event) => {
25
28
  if (replay) return;
26
29
  const target = new URL(location.href);
27
- const previous = new URL(router.getSnapshot().url);
30
+ const previous = new URL(read().url);
31
+ const current = ++sequence;
28
32
  if (target.pathname === previous.pathname && target.search === previous.search) return;
29
33
  event.stopImmediatePropagation();
30
- const current = ++sequence;
31
- destination = target;
32
34
  changed().then(async (updated) => {
33
- if (stopped || current !== sequence) return;
34
- if (updated && await reload(target)) return;
35
+ if (!valid(current)) return;
36
+ if (updated && await reload(target, () => valid(current))) return;
37
+ if (!valid(current)) return;
35
38
  replay = true;
36
39
  window.dispatchEvent(new PopStateEvent("popstate", { state: event.state }));
37
40
  replay = false;
38
41
  });
39
42
  };
40
43
  const preloadError = () => {
41
- if (router.getSnapshot().state !== "loading") return;
44
+ if (read().state !== "loading") return;
42
45
  const current = sequence;
43
- const target = destination;
46
+ const target = new URL(read().url);
44
47
  changed().then((updated) => {
45
- if (updated && current === sequence && !stopped) return reload(target);
48
+ if (updated) return reload(target, () => valid(current) && read().state !== "submitting" && read().url === target.href);
46
49
  });
47
50
  };
48
51
  function cleanup() {
49
52
  stopped = true;
50
53
  sequence++;
51
54
  check.dispose();
55
+ unsubscribe();
52
56
  window.removeEventListener("popstate", pop, true);
53
57
  window.removeEventListener("vite:preloadError", preloadError);
54
58
  if (router.navigate === guarded) router.navigate = navigate;
@@ -50,10 +50,17 @@ async function mountApplication(routes, rootModule, options = false) {
50
50
  rootError: hydration?.rootError
51
51
  };
52
52
  if (hydration) {
53
- hydrate(/* @__PURE__ */ jsx(RouterApplication, {
53
+ const preserved = Array.from(document.head.querySelectorAll(":scope > [data-smolter-preserve]"), (node) => ({
54
+ node,
55
+ next: node.nextSibling
56
+ }));
57
+ for (const { node } of preserved) node.remove();
58
+ await Promise.resolve().then(() => hydrate(/* @__PURE__ */ jsx(RouterApplication, {
54
59
  ...props,
55
60
  document: true
56
- }), document);
61
+ }), document)).finally(() => {
62
+ for (const { node, next } of preserved.reverse()) document.head.insertBefore(node, next?.parentNode === document.head ? next : null);
63
+ });
57
64
  return router;
58
65
  }
59
66
  const target = document.getElementById("root");
@@ -22,9 +22,9 @@ function lazyFiles(directory, dataEndpoint = "/_smolter") {
22
22
  const files = /* @__PURE__ */ new Set();
23
23
  function visit(file) {
24
24
  if (files.has(file)) return;
25
- files.add(file);
26
25
  const dependency = bundle[file];
27
26
  if (dependency?.type !== "chunk") return;
27
+ files.add(file);
28
28
  for (const imported of dependency.imports) visit(imported);
29
29
  }
30
30
  visit(chunk.fileName);
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kasperrt/smolter",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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.3",
59
+ "version": "0.3.5",
60
60
  "description": "A tiny typed data router for React and Preact.",
61
61
  "sideEffects": false,
62
62
  "license": "MIT",