@void/react 1.1.11 → 1.1.12

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.
@@ -6,5 +6,6 @@ const ErrorsContext = createContext(null);
6
6
  const ParamsContext = createContext(null);
7
7
  const RouterContext = createContext(null);
8
8
  const NavigationContext = createContext(idleNavigationState());
9
+ const FrontmatterContext = createContext({});
9
10
  //#endregion
10
- export { SharedContext as a, RouterContext as i, NavigationContext as n, ParamsContext as r, ErrorsContext as t };
11
+ export { RouterContext as a, ParamsContext as i, FrontmatterContext as n, SharedContext as o, NavigationContext as r, ErrorsContext as t };
package/dist/index.d.mts CHANGED
@@ -96,6 +96,7 @@ export declare const ErrorsContext: Context<Record<string, string> | null>;
96
96
  export declare const ParamsContext: Context<Record<string, string> | null>;
97
97
  export declare const RouterContext: Context<VoidRouter | null>;
98
98
  export declare const NavigationContext: Context<NavigationState>;
99
+ export declare const FrontmatterContext: Context<Record<string, unknown>>;
99
100
  //#endregion
100
101
  //#region src/runtime/index.d.ts
101
102
  export type Deferred<T> = Promise<T>;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as SharedContext, i as RouterContext, n as NavigationContext, r as ParamsContext, t as ErrorsContext } from "./context-B0GL7p5m.mjs";
1
+ import { a as RouterContext, i as ParamsContext, n as FrontmatterContext, o as SharedContext, r as NavigationContext, t as ErrorsContext } from "./context-D2ctU4yT.mjs";
2
2
  import { n as setActionRouter, t as action } from "./action-4EvQppia.mjs";
3
3
  import { useActionState, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { VoidActionError, categorizeActionError, formErrorMessage, formRetryMessage, formSubmissionBlocked, getValidationErrors, interpolateRouteParams, isAbortError, isEqualFormValue, isModifiedEvent, mergeDataIntoHref, scheduleFormRetry, ssrProxy, submitAction } from "void/pages-client";
@@ -460,4 +460,4 @@ function useIslandForm(defaults) {
460
460
  };
461
461
  }
462
462
  //#endregion
463
- export { ErrorsContext, Form, Link, NavigationContext, ParamsContext, RouterContext, SharedContext, action, setActionRouter, useForm, useIslandForm, useNavigation, useParams, useRouter, useShared };
463
+ export { ErrorsContext, Form, FrontmatterContext, Link, NavigationContext, ParamsContext, RouterContext, SharedContext, action, setActionRouter, useForm, useIslandForm, useNavigation, useParams, useRouter, useShared };
package/dist/plugin.mjs CHANGED
@@ -3,7 +3,7 @@ import { generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import nodePath from "node:path";
5
5
  import { normalizePath } from "vite";
6
- import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, editIslandSource, findIslandImports, findStaticCssImports, needsIslandClientBundle, resolveSpecifier, rewriteCssImportForVirtualModule, watchPagesForRescan } from "void/pages-islands-plugin";
6
+ import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, editIslandSource, findIslandImports, findStaticCssImports, hasIslandPages, islandStylesPlugin, recordIslandStyles, recordedIslandStyles, resolveSpecifier, rewriteCssImportForVirtualModule, watchPagesForRescan } from "void/pages-islands-plugin";
7
7
  //#region ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
8
8
  const BACKSLASH_RE = /\\/g;
9
9
  const isWindows = process.platform === "win32";
@@ -291,6 +291,9 @@ function islandsPlugin(pagesDir, options) {
291
291
  }
292
292
  }
293
293
  },
294
+ buildEnd() {
295
+ recordIslandStyles(this, pageScan, pagesDir);
296
+ },
294
297
  resolveId: {
295
298
  filter: { id: /^virtual:void-islands-(renderer|client)$/ },
296
299
  handler(source) {
@@ -309,7 +312,7 @@ function islandsPlugin(pagesDir, options) {
309
312
  }
310
313
  if (id === "\0" + ISLANDS_CLIENT_ENTRY_ID) {
311
314
  if (!pageScan) return "";
312
- return generateIslandClientEntry(islandManifest, islandCssFiles, isDev);
315
+ return generateIslandClientEntry(islandManifest, islandCssFiles, recordedIslandStyles(pagesDir), islandManifest.size > 0 || computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, isDev);
313
316
  }
314
317
  }
315
318
  },
@@ -342,14 +345,13 @@ function islandsPlugin(pagesDir, options) {
342
345
  });
343
346
  }
344
347
  },
345
- config(_, env) {
346
- if (!existsSync(pagesDir)) return;
347
- if (env.command !== "serve" && needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false)) return { build: {
348
+ async config(_, env) {
349
+ if (env.command !== "serve" && await hasIslandPages(root, pagesDir)) return { environments: { client: { build: {
348
350
  manifest: true,
349
351
  rollupOptions: { input: { "islands-client": ISLANDS_CLIENT_ENTRY_ID } }
350
- } };
352
+ } } } };
351
353
  }
352
- }];
354
+ }, islandStylesPlugin(pagesDir)];
353
355
  }
354
356
  function generateIslandRenderer(scan, pagesDir, isDev, mdScriptIds, pagesNeedingClientJS) {
355
357
  const preambleScript = isDev ? REACT_REFRESH_PREAMBLE : "";
@@ -358,8 +360,9 @@ function generateIslandRenderer(scan, pagesDir, isDev, mdScriptIds, pagesNeeding
358
360
  return `
359
361
  import React from "react";
360
362
  import { renderToString } from "react-dom/server";
361
- import { SharedContext, ErrorsContext, ParamsContext } from "@void/react";
363
+ import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/react";
362
364
  import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
365
+ import { islandFrontmatterScript } from "void/pages-server";
363
366
 
364
367
  ${generateComponentManifest(scan, pagesDir)}
365
368
  ${mdScriptIdsCode}
@@ -372,7 +375,9 @@ function escapeAttr(s) {
372
375
  }
373
376
 
374
377
  export async function renderIslandPage(pageObj, assetTags) {
375
- const PageComponent = (await components[pageObj.component]()).default;
378
+ const __pageModule = await components[pageObj.component]();
379
+ const PageComponent = __pageModule.default;
380
+ const __frontmatter = __pageModule.frontmatter ?? {};
376
381
  const layoutIds = layoutTree[pageObj.component] || [];
377
382
  const layoutComponents = await Promise.all(
378
383
  layoutIds.map(async (id) => (await components[id]()).default)
@@ -383,7 +388,8 @@ export async function renderIslandPage(pageObj, assetTags) {
383
388
  element = React.createElement(layoutComponents[i], null, element);
384
389
  }
385
390
 
386
- // No RouterContext — islands have no Void Router
391
+ // No RouterContext — islands have no Void Router.
392
+ // Frontmatter wraps the layouts so they can read the Markdown page's values.
387
393
  element = React.createElement(
388
394
  SharedContext.Provider,
389
395
  { value: pageObj.shared || {} },
@@ -393,12 +399,17 @@ export async function renderIslandPage(pageObj, assetTags) {
393
399
  React.createElement(
394
400
  ErrorsContext.Provider,
395
401
  { value: pageObj.errors || {} },
396
- element
402
+ React.createElement(FrontmatterContext.Provider, { value: __frontmatter }, element)
397
403
  )
398
404
  )
399
405
  );
400
406
 
401
407
  const html = renderToString(element);
408
+ // Islands hydrate as separate roots. Send the page frontmatter so their
409
+ // useFrontmatter() returns the same value in the browser.
410
+ const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=")
411
+ ? islandFrontmatterScript(__pageModule.frontmatter)
412
+ : "";
402
413
  const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : "";
403
414
  const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : "";
404
415
  const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : "";
@@ -409,35 +420,42 @@ export async function renderIslandPage(pageObj, assetTags) {
409
420
  <head>\${headHtml}\${PREAMBLE_SCRIPT}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}</head>
410
421
  <body\${bodyAttrs}>
411
422
  <div id="app"\${__mdScriptAttr}>\${html}</div>
412
- \${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
423
+ \${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
413
424
  </body>
414
425
  </html>\`;
415
426
  }
416
427
  `;
417
428
  }
418
- function generateIslandClientEntry(manifest, cssFiles, isDev) {
429
+ function generateIslandClientEntry(manifest, cssFiles, styles, needsRuntime, isDev) {
419
430
  const entries = [...manifest.values()];
420
431
  const uniqueIslands = /* @__PURE__ */ new Map();
421
432
  for (const entry of entries) uniqueIslands.set(entry.islandId, entry.componentPath);
422
433
  const lines = [];
423
- lines.push("import { initIslands } from \"void/pages-client\";");
424
- if (!isDev) for (const file of cssFiles) {
434
+ if (!isDev && styles) for (const id of styles) lines.push(`import ${JSON.stringify(id)};`);
435
+ else if (!isDev) for (const file of cssFiles) {
425
436
  const content = readFileSync(file, "utf-8");
426
437
  for (const item of findStaticCssImports(content, file)) lines.push(rewriteCssImportForVirtualModule(item.statement, item.specifier, file));
427
438
  }
439
+ if (!needsRuntime && !isDev) return lines.join("\n");
440
+ lines.push("import { initIslands } from \"void/pages-client\";");
428
441
  lines.push("const islands = {");
429
442
  for (const [id, path] of uniqueIslands) lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
430
443
  lines.push("};");
431
444
  lines.push(`
432
- async function hydrateIsland(el, mod, props) {
445
+ async function hydrateIsland(el, mod, props, frontmatter) {
433
446
  const { hydrateRoot } = await import("react-dom/client");
434
447
  const React = await import("react");
435
448
  const Component = mod.default || mod;
449
+ let island = React.createElement(Component, props);
450
+ if (Object.keys(frontmatter).length > 0) {
451
+ const { FrontmatterContext } = await import("@void/react");
452
+ island = React.createElement(FrontmatterContext.Provider, { value: frontmatter }, island);
453
+ }
436
454
  function HydratedIsland() {
437
455
  React.useLayoutEffect(() => {
438
456
  el.setAttribute("data-hydrated", "true");
439
457
  }, []);
440
- return React.createElement(Component, props);
458
+ return island;
441
459
  }
442
460
  hydrateRoot(el, React.createElement(HydratedIsland));
443
461
  }
@@ -1,7 +1,7 @@
1
- import { a as SharedContext, i as RouterContext, n as NavigationContext, r as ParamsContext, t as ErrorsContext } from "../context-B0GL7p5m.mjs";
1
+ import { a as RouterContext, i as ParamsContext, o as SharedContext, r as NavigationContext, t as ErrorsContext } from "../context-D2ctU4yT.mjs";
2
2
  import { n as setActionRouter } from "../action-4EvQppia.mjs";
3
3
  import { n as DEFERRED_SHELL_END_ID } from "../deferred-shell-DOuP2YAo.mjs";
4
- import React, { Suspense, startTransition, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useTransition } from "react";
4
+ import React, { Suspense, startTransition, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState, useTransition } from "react";
5
5
  import { createPageNavigationEngine, createRouterFacade, idleNavigationState, isAbortError, isCallSiteActionError } from "void/pages-client";
6
6
  import { createRoot, hydrateRoot } from "react-dom/client";
7
7
  //#region src/runtime/pages-client.ts
@@ -11,6 +11,7 @@ const pendingVisitSettlers = /* @__PURE__ */ new Map();
11
11
  const committedResources = /* @__PURE__ */ new Set();
12
12
  const EMPTY_PARAMS = {};
13
13
  let activePendingNavigationId = null;
14
+ let prerenderedUrl = null;
14
15
  let scheduleVisit = null;
15
16
  function createDeferredResource() {
16
17
  let resolve;
@@ -54,6 +55,36 @@ function normalizeUrl(value) {
54
55
  const url = value instanceof URL ? value : new URL(value, window.location.origin);
55
56
  return url.origin === window.location.origin ? url.pathname + url.search + url.hash : url.href;
56
57
  }
58
+ /** Page data holds a decoded path. Compare it with browser URLs in their encoded form. */
59
+ function encodedPageUrl(url) {
60
+ if (!url) return url;
61
+ const parsed = new URL(url, window.location.origin);
62
+ return parsed.pathname + parsed.search;
63
+ }
64
+ /** Static hosts serve one page at `/a`, `/a/`, `/a.html` and `/a/index.html`. */
65
+ function staticPagePath(pathname) {
66
+ let path = pathname;
67
+ if (path.endsWith("/index.html")) path = path.slice(0, -10);
68
+ else if (path.endsWith(".html")) path = path.slice(0, -5);
69
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
70
+ }
71
+ /**
72
+ * Static page data serves every query of a path, so its url has no query. Returns the query of
73
+ * fetchUrl when the page data is such data for its path, or null.
74
+ */
75
+ function staticVisitQuery(pageUrl, fetchUrl) {
76
+ if (!pageUrl || pageUrl.includes("?")) return null;
77
+ const queryStart = fetchUrl.indexOf("?");
78
+ if (staticPagePath(queryStart === -1 ? fetchUrl : fetchUrl.slice(0, queryStart)) !== staticPagePath(pageUrl)) return null;
79
+ return queryStart === -1 ? "" : fetchUrl.slice(queryStart);
80
+ }
81
+ /**
82
+ * The URL prerendered HTML was rendered with, when it lacks the visitor's query, or null.
83
+ */
84
+ function prerenderedRouteUrl(pageData) {
85
+ const pageUrl = encodedPageUrl(pageData.url);
86
+ return staticVisitQuery(pageUrl, window.location.pathname + window.location.search) ? pageUrl + window.location.hash : null;
87
+ }
57
88
  function navigationForResource(resource) {
58
89
  const method = resource.method.toUpperCase();
59
90
  return {
@@ -159,13 +190,20 @@ function RouteResource({ resource, onCommit }) {
159
190
  const pageData = result.pageData;
160
191
  const PageComponent = result.component;
161
192
  const routeParams = pageData.params ?? EMPTY_PARAMS;
162
- const routeRouter = useMemo(() => createRouterFacade(outerRouter, () => {
163
- const outerUrl = outerRouter.url;
164
- const outerPath = outerUrl.pathname + outerUrl.search;
165
- return outerPath === pageData.url ? outerPath + outerUrl.hash : pageData.url + resource.hash;
166
- }, () => routeParams), [
193
+ const routeRouter = useMemo(() => {
194
+ const pageUrl = encodedPageUrl(pageData.url);
195
+ return createRouterFacade(outerRouter, () => {
196
+ const outerUrl = outerRouter.url;
197
+ const outerPath = outerUrl.pathname + outerUrl.search;
198
+ if (outerPath === pageUrl) return outerPath + outerUrl.hash;
199
+ const query = staticVisitQuery(pageUrl, resource.fetchUrl);
200
+ if (query !== null) return pageUrl + query + (outerPath === resource.fetchUrl ? outerUrl.hash : resource.hash);
201
+ return pageUrl + resource.hash;
202
+ }, () => routeParams);
203
+ }, [
167
204
  outerRouter,
168
205
  pageData.url,
206
+ resource.fetchUrl,
169
207
  resource.hash,
170
208
  routeParams
171
209
  ]);
@@ -193,10 +231,11 @@ function startViewTransition(resource, reveal, enabled) {
193
231
  }
194
232
  function App({ engine, router, viewTransitions, navigationFallback: NavigationFallback }) {
195
233
  const initialPageData = engine.initialPageData;
234
+ const [hydrated, setHydrated] = useState(prerenderedUrl === null);
196
235
  const initialResource = useMemo(() => ({
197
236
  id: 0,
198
237
  url: initialPageData.url || currentUrl(),
199
- fetchUrl: initialPageData.url || currentUrl(),
238
+ fetchUrl: window.location.pathname + window.location.search,
200
239
  hash: window.location.hash,
201
240
  method: "GET",
202
241
  options: {},
@@ -210,6 +249,10 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
210
249
  async commit() {
211
250
  engine.commitInitialRender();
212
251
  document.getElementById("app").setAttribute("data-hydrated", "true");
252
+ if (prerenderedUrl !== null) {
253
+ prerenderedUrl = null;
254
+ React.startTransition(() => setHydrated(true));
255
+ }
213
256
  },
214
257
  dispose() {}
215
258
  }), [engine, initialPageData]);
@@ -403,7 +446,11 @@ function App({ engine, router, viewTransitions, navigationFallback: NavigationFa
403
446
  method: "GET"
404
447
  };
405
448
  }, [isTransitionPending, state.navigation]);
406
- const routerValue = useMemo(() => createRouterFacade(router, () => state.routeUrl), [router, state.routeUrl]);
449
+ const routerValue = useMemo(() => createRouterFacade(router, () => hydrated ? state.routeUrl : prerenderedUrl ?? state.routeUrl), [
450
+ router,
451
+ state.routeUrl,
452
+ hydrated
453
+ ]);
407
454
  return React.createElement(RouterContext.Provider, { value: routerValue }, React.createElement(NavigationContext.Provider, { value: navigationValue }, React.createElement(Suspense, { fallback: NavigationFallback && state.pendingId !== null ? React.createElement(NavigationFallback, { initialPageData }) : null }, React.createElement(RouteResource, {
408
455
  resource: state.resource,
409
456
  onCommit: finishNavigation
@@ -414,6 +461,7 @@ async function startReactPages(config) {
414
461
  pendingVisitSettlers.clear();
415
462
  committedResources.clear();
416
463
  activePendingNavigationId = null;
464
+ prerenderedUrl = null;
417
465
  const engine = await createPageNavigationEngine({
418
466
  adapter: {
419
467
  createDeferred: createDeferredResource,
@@ -436,13 +484,14 @@ async function startReactPages(config) {
436
484
  setActionRouter(router);
437
485
  const appEl = document.getElementById("app");
438
486
  if (!appEl) throw new Error("pages: Missing #app element for Void React pages hydration.");
487
+ const isSsr = appEl.getAttribute("data-void-ssr") !== "false";
488
+ if (isSsr) prerenderedUrl = prerenderedRouteUrl(engine.initialPageData);
439
489
  const appRoot = React.createElement(App, {
440
490
  engine,
441
491
  router,
442
492
  viewTransitions: config.viewTransitions,
443
493
  navigationFallback: config.navigationFallback
444
494
  });
445
- const isSsr = appEl.getAttribute("data-void-ssr") !== "false";
446
495
  const hasDeferred = Boolean(engine.initialPageData.deferredKeys);
447
496
  const app = isSsr && hasDeferred ? React.createElement(React.Fragment, null, appRoot, React.createElement("template", { id: DEFERRED_SHELL_END_ID })) : appRoot;
448
497
  startTransition(() => {
@@ -1,4 +1,4 @@
1
- import { a as SharedContext, i as RouterContext, n as NavigationContext, r as ParamsContext, t as ErrorsContext } from "../context-B0GL7p5m.mjs";
1
+ import { a as RouterContext, i as ParamsContext, o as SharedContext, r as NavigationContext, t as ErrorsContext } from "../context-D2ctU4yT.mjs";
2
2
  import { n as DEFERRED_SHELL_END_ID, r as DEFERRED_SHELL_END_MARKER, t as DEFERRED_SHELL_CANCEL_REASON } from "../deferred-shell-DOuP2YAo.mjs";
3
3
  import React from "react";
4
4
  import { createSsrRouter, idleNavigationState } from "void/pages-client";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/react",
3
- "version": "1.1.11",
3
+ "version": "1.1.12",
4
4
  "description": "React pages, server rendering, forms, and navigation for Void.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -49,13 +49,13 @@
49
49
  "react-dom": "^19.3.0",
50
50
  "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
51
51
  "vite-plus": "1.2.0",
52
- "void": "1.1.11"
52
+ "void": "1.1.12"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "^19.0.0",
56
56
  "react-dom": "^19.0.0",
57
57
  "vite": "^8.0.0",
58
- "void": "1.1.11"
58
+ "void": "1.1.12"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "vp pack",