@pajecawav/yamf 0.0.8 → 0.0.10

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.
Files changed (59) hide show
  1. package/README.md +63 -7
  2. package/dist/client/index.js +39 -1
  3. package/dist/client/index.js.map +1 -0
  4. package/dist/components/Head.d.ts +2 -2
  5. package/dist/components/Head.js.map +1 -1
  6. package/dist/hooks/isPrerendering.d.ts +12 -0
  7. package/dist/hooks/isPrerendering.d.ts.map +1 -0
  8. package/dist/hooks/isPrerendering.js +17 -0
  9. package/dist/hooks/isPrerendering.js.map +1 -0
  10. package/dist/hooks/useHead.d.ts +9 -3
  11. package/dist/hooks/useHead.d.ts.map +1 -1
  12. package/dist/hooks/useHead.js +46 -4
  13. package/dist/hooks/useHead.js.map +1 -1
  14. package/dist/index.d.ts +6 -2
  15. package/dist/index.js +3 -1
  16. package/dist/island/client.js +45 -36
  17. package/dist/island/client.js.map +1 -1
  18. package/dist/page.d.ts +31 -4
  19. package/dist/page.d.ts.map +1 -1
  20. package/dist/page.js +60 -6
  21. package/dist/page.js.map +1 -1
  22. package/dist/safeAsync.d.ts +19 -0
  23. package/dist/safeAsync.d.ts.map +1 -0
  24. package/dist/safeAsync.js +34 -0
  25. package/dist/safeAsync.js.map +1 -0
  26. package/dist/server/entry.d.mts +9 -0
  27. package/dist/server/entry.d.mts.map +1 -1
  28. package/dist/server/entry.mjs +7 -2
  29. package/dist/server/entry.mjs.map +1 -1
  30. package/dist/server/island/server.d.mts.map +1 -1
  31. package/dist/server/island/server.mjs +11 -1
  32. package/dist/server/island/server.mjs.map +1 -1
  33. package/dist/vite/index.d.mts.map +1 -1
  34. package/dist/vite/index.mjs +12 -4
  35. package/dist/vite/index.mjs.map +1 -1
  36. package/dist/vite/islands.mjs +34 -5
  37. package/dist/vite/islands.mjs.map +1 -1
  38. package/dist/vite/prerender.mjs +16 -0
  39. package/dist/vite/prerender.mjs.map +1 -0
  40. package/dist/vite/virtual-root.mjs +11 -4
  41. package/dist/vite/virtual-root.mjs.map +1 -1
  42. package/dist/vite/virtual-template.mjs +8 -3
  43. package/dist/vite/virtual-template.mjs.map +1 -1
  44. package/package.json +23 -16
  45. package/src/client/index.ts +51 -1
  46. package/src/components/Head.tsx +2 -2
  47. package/src/hooks/isPrerendering.ts +13 -0
  48. package/src/hooks/useHead.tsx +98 -9
  49. package/src/index.ts +10 -1
  50. package/src/island/client.tsx +77 -65
  51. package/src/island/server.tsx +24 -1
  52. package/src/page.tsx +176 -11
  53. package/src/safeAsync.tsx +46 -0
  54. package/src/server/entry.tsx +25 -1
  55. package/src/vite/index.ts +30 -3
  56. package/src/vite/islands.ts +59 -5
  57. package/src/vite/prerender.ts +27 -0
  58. package/src/vite/virtual-root.ts +14 -4
  59. package/src/vite/virtual-template.ts +15 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pajecawav/yamf",
3
3
  "type": "module",
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "description": "Yet another meta framework",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/pajecawav/yamf#readme",
@@ -30,36 +30,43 @@
30
30
  "access": "public"
31
31
  },
32
32
  "peerDependencies": {
33
- "hono": "^4.12.30",
34
- "vite": "^8.1.5"
33
+ "hono": "^4.13.9",
34
+ "vite": "^8.3.0",
35
+ "wouter": "^3.11.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "wouter": {
39
+ "optional": true
40
+ }
35
41
  },
36
42
  "dependencies": {
37
43
  "@hono/react-compat": "^0.0.3",
38
- "devalue": "^5.9.2",
39
- "exsolve": "^1.1.1",
44
+ "@standard-schema/spec": "^1.1.0",
45
+ "devalue": "^6.0.2",
46
+ "exsolve": "^1.1.3",
40
47
  "nitro": "3.0.260903-beta",
41
48
  "rolldown-string": "^0.3.1",
42
- "rou3": "^0.9.2",
49
+ "rou3": "^1.0.0",
43
50
  "ufo": "^1.6.4",
44
- "unhead": "^3.4.0",
45
- "wouter": "^3.11.0"
51
+ "unhead": "^3.4.2",
52
+ "wouter": "^3.13.0"
46
53
  },
47
54
  "devDependencies": {
48
55
  "@hono/react-compat": "^0.0.3",
49
- "@pajecawav/tools": "^0.0.9",
56
+ "@pajecawav/tools": "^0.1.0",
50
57
  "@playwright/test": "^1.63.0",
51
- "@types/node": "^26.4.1",
58
+ "@types/node": "^26.6.4",
52
59
  "cross-env": "^10.1.0",
53
- "hono": "^4.13.7",
60
+ "hono": "^4.13.12",
54
61
  "husky": "^9.1.7",
55
62
  "npm-run-all2": "^9.0.3",
56
- "oxfmt": "^0.66.0",
57
- "oxlint": "^1.81.0",
58
- "oxlint-tsgolint": "^7.0.2001",
59
- "publint": "^0.3.24",
63
+ "oxfmt": "^0.71.0",
64
+ "oxlint": "^1.86.0",
65
+ "oxlint-tsgolint": "^7.0.2002",
66
+ "publint": "^0.3.25",
60
67
  "tsdown": "0.23.0",
61
68
  "typescript": "~7.0.2",
62
- "vite": "^8.2.2"
69
+ "vite": "^8.3.2"
63
70
  },
64
71
  "scripts": {
65
72
  "build": "tsdown",
@@ -1 +1,51 @@
1
- import "../island/client";
1
+ /**
2
+ * Tiny bootstrap for the island runtime.
3
+ *
4
+ * Defines the <yamf-island> custom element and dynamically imports the real
5
+ * hydration runtime (hono/jsx dom renderer, devalue, unhead client) only when
6
+ * the document actually contains an island. Pages — or whole apps — without
7
+ * islands never pay for it.
8
+ */
9
+
10
+ const pending: Set<HTMLElement> = new Set();
11
+
12
+ type Hydrate = (island: HTMLElement) => void;
13
+
14
+ let hydrate: Hydrate | null = null;
15
+ let loading: boolean = false;
16
+
17
+ const loadRuntime = (): void => {
18
+ if (loading) {
19
+ return;
20
+ }
21
+
22
+ loading = true;
23
+
24
+ void import("../island/client").then(({ hydrateIsland }) => {
25
+ hydrate = hydrateIsland;
26
+
27
+ for (const island of pending) {
28
+ pending.delete(island);
29
+ hydrate(island);
30
+ }
31
+ });
32
+ };
33
+
34
+ customElements.define(
35
+ "yamf-island",
36
+ class extends HTMLElement {
37
+ public connectedCallback() {
38
+ if (hydrate) {
39
+ hydrate(this);
40
+ return;
41
+ }
42
+
43
+ pending.add(this);
44
+ loadRuntime();
45
+ }
46
+
47
+ public disconnectedCallback() {
48
+ pending.delete(this);
49
+ }
50
+ },
51
+ );
@@ -1,7 +1,7 @@
1
- import type { ResolvableHead } from "unhead/types";
2
1
  import { useHead } from "#/hooks/useHead";
2
+ import type { YamfHead } from "#/shared/head";
3
3
 
4
- export type HeadProps = ResolvableHead;
4
+ export type HeadProps = YamfHead;
5
5
 
6
6
  export const Head = (props: HeadProps): void => {
7
7
  useHead(props);
@@ -0,0 +1,13 @@
1
+ import { useSSRContext } from "#/context/ssr";
2
+
3
+ /**
4
+ * Whether the current render runs inside Nitro's build-time prerender pass —
5
+ * the prerenderer fetching the route to write it to disk as a static file.
6
+ *
7
+ * Always `false` in dev and at request time. Prefer this over
8
+ * `import.meta.prerender`: pages are bundled by the vite ssr service, where
9
+ * that flag is never replaced and would always read as `undefined`.
10
+ */
11
+ export const isPrerendering = (): boolean => {
12
+ return useSSRContext()?.event.req.headers.has("x-nitro-prerender") ?? false;
13
+ };
@@ -1,39 +1,128 @@
1
+ import { useEffect, useRef } from "hono/jsx";
1
2
  import { useHead as _useHead, useSeoMeta as _useSeoMeta } from "unhead";
2
3
  import type { ClientUnhead } from "unhead/client";
3
4
  import { createHead } from "unhead/client";
4
- import type { ResolvableHead, UseSeoMetaInput } from "unhead/types";
5
+ import type { ActiveHeadEntry, ResolvableHead, UseSeoMetaInput } from "unhead/types";
5
6
  import { useSSRContext } from "#/context/ssr";
7
+ import type { YamfHead } from "#/shared/head";
8
+
9
+ interface StreamQueue {
10
+ _q?: unknown[][];
11
+ push?: (batch: unknown[]) => void;
12
+ }
6
13
 
7
14
  declare global {
8
15
  interface Window {
9
- __UNHEAD__?: ClientUnhead;
16
+ __unhead__?: StreamQueue;
17
+ __yamfHead__?: ClientUnhead;
10
18
  }
11
19
  }
12
20
 
13
21
  if (!import.meta.env.SSR) {
14
- window.__UNHEAD__ = createHead();
22
+ const head = createHead();
23
+
24
+ const drain = (batch: unknown[]): void => {
25
+ for (const input of batch) {
26
+ head.push(input as ResolvableHead);
27
+ }
28
+ };
29
+
30
+ // inline scripts (the handshake payload and streamed suspense patches)
31
+ // queue into window.__unhead__ while the document parses; this module is
32
+ // part of the deferred client entry and runs after parsing, so by now the
33
+ // queue holds everything the server sent — drain it into the client head
34
+ const queue = window.__unhead__?._q;
35
+
36
+ if (queue) {
37
+ for (const batch of queue) {
38
+ drain(batch);
39
+ }
40
+ }
41
+
42
+ // keep late patches (a stream that is still open) working
43
+ window.__unhead__ = { push: drain };
44
+
45
+ window.__yamfHead__ = head;
15
46
  }
16
47
 
17
- export const useHead = (input?: ResolvableHead): void => {
48
+ /**
49
+ * Binds an unhead entry to the calling component's lifecycle: the entry is
50
+ * created once on mount, patched on every re-render with the latest input,
51
+ * and disposed on unmount. Without this, every re-render pushes a new entry
52
+ * that is never removed — a leak and growing DOM render cost.
53
+ */
54
+ const useHeadEntryLifecycle = <T,>(
55
+ input: T | undefined,
56
+ createEntry: (head: ClientUnhead, input: T) => ActiveHeadEntry<T>,
57
+ ): void => {
58
+ const entryRef = useRef<ActiveHeadEntry<T> | null>(null);
59
+
60
+ useEffect(() => {
61
+ const head = window.__yamfHead__;
62
+
63
+ if (!head || input === undefined) {
64
+ return;
65
+ }
66
+
67
+ const entry = createEntry(head, input);
68
+
69
+ entryRef.current = entry;
70
+
71
+ return () => {
72
+ entry.dispose();
73
+ entryRef.current = null;
74
+ };
75
+ // mount-only: the entry is created once per component instance
76
+ // oxlint-disable-next-line eslint(exhaustive-deps)
77
+ }, []);
78
+
79
+ useEffect(() => {
80
+ if (input !== undefined) {
81
+ entryRef.current?.patch(input);
82
+ }
83
+ }, [input]);
84
+ };
85
+
86
+ export const useHead = (input?: YamfHead): void => {
18
87
  if (import.meta.env.SSR) {
88
+ if (input === undefined) {
89
+ return;
90
+ }
91
+
19
92
  const ctx = useSSRContext();
20
93
 
21
94
  if (ctx?.head) {
22
- _useHead(ctx.head, input);
95
+ // the custom `seo` key is yamf sugar for useSeoMeta — process it
96
+ // instead of passing an unknown key down to unhead
97
+ const { seo, ...rest } = input;
98
+
99
+ if (seo !== undefined) {
100
+ _useSeoMeta(ctx.head, seo);
101
+ }
102
+
103
+ _useHead(ctx.head, rest);
23
104
  }
24
- } else if (window.__UNHEAD__) {
25
- _useHead(window.__UNHEAD__, input);
105
+
106
+ return;
26
107
  }
108
+
109
+ useHeadEntryLifecycle(input, (head, resolved) => _useHead(head, resolved));
27
110
  };
28
111
 
29
112
  export const useSeoMeta = (input?: UseSeoMetaInput): void => {
30
113
  if (import.meta.env.SSR) {
114
+ if (input === undefined) {
115
+ return;
116
+ }
117
+
31
118
  const ctx = useSSRContext();
32
119
 
33
120
  if (ctx?.head) {
34
121
  _useSeoMeta(ctx.head, input);
35
122
  }
36
- } else if (window.__UNHEAD__) {
37
- _useSeoMeta(window.__UNHEAD__, input);
123
+
124
+ return;
38
125
  }
126
+
127
+ useHeadEntryLifecycle(input, (head, resolved) => _useSeoMeta(head, resolved));
39
128
  };
package/src/index.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  export { definePage } from "./page";
2
- export type { PageHandler } from "./page";
2
+ export type { PageCacheOptions, PageHandler, PageRenderer } from "./page";
3
+
4
+ export type { StandardSchemaV1 } from "@standard-schema/spec";
5
+
6
+ // re-exported so apps can annotate render args without depending on unhead
7
+ export type { Unhead } from "unhead/server";
8
+
9
+ export { safeAsync } from "./safeAsync";
10
+ export type { SafeAsyncFallbackProps } from "./safeAsync";
3
11
 
4
12
  export { useSSRContext } from "./context/ssr";
5
13
  export { useEvent } from "./hooks/useEvent";
14
+ export { isPrerendering } from "./hooks/isPrerendering";
6
15
  export { useHead, useSeoMeta } from "./hooks/useHead";
7
16
 
8
17
  export type { IslandClientDirective, IslandProps } from "./island/types";
@@ -4,11 +4,11 @@ import { hydrateRoot } from "hono/jsx/dom/client";
4
4
  import { withLeadingSlash } from "ufo";
5
5
  import type { IslandClientDirectiveSerialized } from "./types";
6
6
 
7
- declare let __island_raw_import__: <T>(file: string) => Promise<T>;
7
+ declare let __yamf_raw_import__: <T>(file: string) => Promise<T>;
8
8
 
9
- const listeners = new WeakMap<Element, VoidFunction>();
9
+ const listeners: WeakMap<Element, VoidFunction> = new WeakMap();
10
10
 
11
- const observer = new IntersectionObserver(entries => {
11
+ const observer: IntersectionObserver = new IntersectionObserver(entries => {
12
12
  for (const entry of entries) {
13
13
  if (entry.isIntersecting) {
14
14
  listeners.get(entry.target)?.();
@@ -27,69 +27,81 @@ const unobserve = (target: Element) => {
27
27
  listeners.delete(target);
28
28
  };
29
29
 
30
- customElements.define(
31
- "yamf-island",
32
- class extends HTMLElement {
33
- public connectedCallback() {
34
- const islandProps = parse(this.getAttribute("island-props") ?? "{}");
35
- const islandSrc = this.getAttribute("island-src");
36
- const islandEntry = this.getAttribute("island-entry");
37
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion
38
- const islandClient = (this.getAttribute("island-client") ??
39
- "load") as IslandClientDirectiveSerialized;
40
-
41
- if (!islandSrc) {
42
- throw new Error("Missing island-src attribute");
43
- }
30
+ // Safari does not enable requestIdleCallback by default in any stable
31
+ // release — without a fallback, client:idle islands never hydrate there
32
+ const requestIdle = (callback: () => void): void => {
33
+ const ric = window.requestIdleCallback;
44
34
 
45
- if (!islandEntry) {
46
- throw new Error("Missing island-entry attribute");
47
- }
35
+ if (typeof ric === "function") {
36
+ ric(callback);
37
+ return;
38
+ }
48
39
 
49
- const hydrateIsland = (mod: Record<string, FC>) => {
50
- const Comp = mod[islandEntry];
51
-
52
- if (!Comp) {
53
- throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
54
- }
55
-
56
- hydrateRoot(this, <Comp {...islandProps} />);
57
- };
58
-
59
- const initIsland = () => {
60
- const src = withLeadingSlash(islandSrc);
61
- void __island_raw_import__<Record<string, FC>>(src).then(hydrateIsland);
62
- };
63
-
64
- switch (islandClient) {
65
- case "true":
66
- case "load":
67
- initIsland();
68
- break;
69
- case "idle":
70
- requestIdleCallback(initIsland);
71
- break;
72
- case "visible":
73
- // yamf-island has `display: contents` which breaks IntersectionObserver
74
- // so we have to observe the first child instead if it exists
75
- if (this.firstElementChild) {
76
- observe(this.firstElementChild, initIsland);
77
- } else {
78
- initIsland();
79
- }
80
- break;
81
- case "false":
82
- case "skip":
83
- break;
84
- default:
85
- islandClient satisfies never;
86
- // oxlint-disable-next-line typescript/restrict-template-expressions
87
- throw new Error(`Invalid island-client value: ${islandClient}`);
88
- }
89
- }
40
+ setTimeout(callback, 1);
41
+ };
42
+
43
+ /**
44
+ * Hydrates a single <yamf-island> island. Called by the bootstrap custom
45
+ * element (src/client/index.ts) once this runtime module has been loaded.
46
+ */
47
+ export const hydrateIsland = (island: HTMLElement): void => {
48
+ // skip islands omit the attribute entirely — parse after (and only if) it
49
+ // exists, otherwise parse("{}") would throw "Invalid input" and abort the
50
+ // whole pending-hydration loop
51
+ const rawProps = island.getAttribute("island-props");
52
+ const islandProps = rawProps === null ? [] : parse(rawProps);
53
+ const islandSrc = island.getAttribute("island-src");
54
+ const islandEntry = island.getAttribute("island-entry");
55
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
56
+ const islandClient = (island.getAttribute("island-client") ??
57
+ "load") as IslandClientDirectiveSerialized;
58
+
59
+ if (!islandSrc) {
60
+ throw new Error("Missing island-src attribute");
61
+ }
90
62
 
91
- public disconnectedCallback() {
92
- unobserve(this);
63
+ if (!islandEntry) {
64
+ throw new Error("Missing island-entry attribute");
65
+ }
66
+
67
+ const hydrate = (mod: Record<string, FC>) => {
68
+ const Comp = mod[islandEntry];
69
+
70
+ if (!Comp) {
71
+ throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
93
72
  }
94
- },
95
- );
73
+
74
+ hydrateRoot(island, <Comp {...islandProps} />);
75
+ };
76
+
77
+ const initIsland = () => {
78
+ const src = withLeadingSlash(islandSrc);
79
+ void __yamf_raw_import__<Record<string, FC>>(src).then(hydrate);
80
+ };
81
+
82
+ switch (islandClient) {
83
+ case "true":
84
+ case "load":
85
+ initIsland();
86
+ break;
87
+ case "idle":
88
+ requestIdle(initIsland);
89
+ break;
90
+ case "visible":
91
+ // yamf-island has `display: contents` which breaks IntersectionObserver
92
+ // so we have to observe the first child instead if it exists
93
+ if (island.firstElementChild) {
94
+ observe(island.firstElementChild, initIsland);
95
+ } else {
96
+ initIsland();
97
+ }
98
+ break;
99
+ case "false":
100
+ case "skip":
101
+ break;
102
+ default:
103
+ islandClient satisfies never;
104
+ // oxlint-disable-next-line typescript/restrict-template-expressions
105
+ throw new Error(`Invalid island-client value: ${islandClient}`);
106
+ }
107
+ };
@@ -3,6 +3,9 @@ import type { Child, FC } from "hono/jsx";
3
3
  import type { ImportAssetsResultRaw } from "#/shared/assets";
4
4
  import type { IslandClientDirective, IslandProps } from "./types";
5
5
 
6
+ // configurable via the YAMF_ISLAND_PROPS_LIMIT env / define
7
+ const PROPS_WARNING_LIMIT = Number(import.meta.env["YAMF_ISLAND_PROPS_LIMIT"]) || 16 * 1024;
8
+
6
9
  declare module "hono/jsx" {
7
10
  namespace JSX {
8
11
  interface IntrinsicElements {
@@ -18,6 +21,10 @@ declare module "hono/jsx" {
18
21
  }
19
22
  }
20
23
 
24
+ const isSkipDirective = (directive: IslandClientDirective | undefined): boolean => {
25
+ return directive === false || directive === "skip";
26
+ };
27
+
21
28
  export const createIsland = (
22
29
  Component: FC,
23
30
  exportName: string,
@@ -31,9 +38,25 @@ export const createIsland = (
31
38
  throw new Error(`Missing island entry for island ${Component.name}`);
32
39
  }
33
40
 
41
+ // skip islands are never hydrated — serializing their props into the
42
+ // attribute would be pure dead weight in the HTML
43
+ const skip = isSkipDirective(clientDirective);
44
+
45
+ let islandProps: string | undefined;
46
+
47
+ if (!skip) {
48
+ islandProps = stringify(props);
49
+
50
+ if (import.meta.env.DEV && islandProps.length > PROPS_WARNING_LIMIT) {
51
+ console.warn(
52
+ `[yamf] island "${exportName}" receives ${(islandProps.length / 1024).toFixed(1)} KB of serialized props — consider slimming them down (limit: ${PROPS_WARNING_LIMIT} bytes, configurable via YAMF_ISLAND_PROPS_LIMIT)`,
53
+ );
54
+ }
55
+ }
56
+
34
57
  return (
35
58
  <yamf-island
36
- island-props={stringify(props)}
59
+ island-props={islandProps}
37
60
  island-src={assets.entry}
38
61
  island-entry={exportName}
39
62
  island-client={clientDirective}