@stratal/inertia 0.0.27 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +379 -0
  2. package/README.md +158 -14
  3. package/dist/build-seo-tags-DBsHKxX9.mjs.map +1 -1
  4. package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
  5. package/dist/generator/type-generator.worker.d.mts +1 -1
  6. package/dist/generator/type-generator.worker.mjs +1 -1
  7. package/dist/index.d.mts +214 -92
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +390 -130
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/quarry.d.mts +6 -8
  12. package/dist/quarry.d.mts.map +1 -1
  13. package/dist/quarry.mjs +127 -12
  14. package/dist/quarry.mjs.map +1 -1
  15. package/dist/react/access.d.mts +95 -0
  16. package/dist/react/access.d.mts.map +1 -0
  17. package/dist/react/access.mjs +133 -0
  18. package/dist/react/access.mjs.map +1 -0
  19. package/dist/react-dom-server-legacy-stub.d.mts +20 -0
  20. package/dist/react-dom-server-legacy-stub.d.mts.map +1 -0
  21. package/dist/react-dom-server-legacy-stub.mjs +25 -0
  22. package/dist/react-dom-server-legacy-stub.mjs.map +1 -0
  23. package/dist/react.d.mts +4 -6
  24. package/dist/react.d.mts.map +1 -1
  25. package/dist/react.mjs +1 -1
  26. package/dist/react.mjs.map +1 -1
  27. package/dist/seo-runtime.d.mts +1 -1
  28. package/dist/seo-runtime.mjs +8 -6
  29. package/dist/seo-runtime.mjs.map +1 -1
  30. package/dist/services/ssr-exclusion.d.mts +38 -0
  31. package/dist/services/ssr-exclusion.d.mts.map +1 -0
  32. package/dist/services/ssr-exclusion.mjs +0 -0
  33. package/dist/services/ssr-exclusion.mjs.map +1 -0
  34. package/dist/ssr.d.mts +37 -8
  35. package/dist/ssr.d.mts.map +1 -1
  36. package/dist/ssr.mjs +7 -4
  37. package/dist/ssr.mjs.map +1 -1
  38. package/dist/testing.d.mts +3 -2
  39. package/dist/testing.d.mts.map +1 -1
  40. package/dist/testing.mjs +20 -6
  41. package/dist/testing.mjs.map +1 -1
  42. package/dist/{type-generator-DFpha_Fp.mjs → type-generator-BVw8mj1y.mjs} +373 -64
  43. package/dist/type-generator-BVw8mj1y.mjs.map +1 -0
  44. package/dist/types-BltKoOR7.d.mts +193 -0
  45. package/dist/types-BltKoOR7.d.mts.map +1 -0
  46. package/dist/types-D-j_Ee_h.d.mts +52 -0
  47. package/dist/types-D-j_Ee_h.d.mts.map +1 -0
  48. package/dist/types-DzE1pdZs.d.mts.map +1 -1
  49. package/dist/vite.d.mts +19 -6
  50. package/dist/vite.d.mts.map +1 -1
  51. package/dist/vite.mjs +67 -5
  52. package/dist/vite.mjs.map +1 -1
  53. package/package.json +38 -27
  54. package/dist/type-generator-DFpha_Fp.mjs.map +0 -1
  55. package/dist/types-BhgXhWx6.d.mts +0 -82
  56. package/dist/types-BhgXhWx6.d.mts.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access.mjs","names":[],"sources":["../../src/react/access/match.ts","../../src/react/access/missing-access-props.error.ts","../../src/react/access/use-access.ts","../../src/react/access/components.ts"],"sourcesContent":["import type { Check, SharedAccess } from '../../access/types'\n\n/**\n * Resolves a permission string against a shared permission set.\n *\n * The grammar matches `AuthGuard` exactly, so a string copy-pasted between a\n * guard and a component means the same thing in both places:\n *\n * - `posts:update` — that specific action\n * - `posts` or `posts:*` — any action on the resource\n *\n * A single-permission check here is equivalent to the server's `AuthGuard`\n * check. An `all` group is not: `evaluate()` ANDs over the caller's merged\n * permissions across every held role, while `AuthGuard` requires one single\n * role to satisfy the entire set. A user with two roles that each satisfy\n * half of an `all` list can pass here and still get a 403 from `AuthGuard`.\n */\nexport function matchesPermission(access: SharedAccess, permission: string): boolean {\n const colon = permission.indexOf(':')\n const resource = colon === -1 ? permission : permission.slice(0, colon)\n const action = colon === -1 ? '*' : permission.slice(colon + 1)\n\n const actions = access.permissions[resource]\n if (!actions || actions.length === 0) return false\n\n return action === '*' || actions.includes(action)\n}\n\n/** Resolves a role name against the user's held roles. */\nexport function matchesRole(access: SharedAccess, role: string): boolean {\n return access.roles.includes(role)\n}\n\n/** Applies a matcher across a single value, an `any` group, or an `all` group. */\nexport function evaluate<T extends string>(\n access: SharedAccess,\n check: Check<T>,\n matches: (access: SharedAccess, value: T) => boolean,\n): boolean {\n if (typeof check === 'string') return matches(access, check)\n if ('any' in check) return check.any.some((value) => matches(access, value))\n return check.all.every((value) => matches(access, value))\n}\n","/**\n * Thrown when a component or hook from `@stratal/inertia/react/access` runs on\n * a page with no `access` prop.\n *\n * This is always a wiring problem, never a permission problem — a user with no\n * permissions gets `{ roles: [], permissions: {} }`. Failing loudly keeps\n * \"misconfigured\" from being indistinguishable from \"denied\", which would fail\n * open the moment the middleware stopped running.\n */\nexport class MissingAccessPropsError extends Error {\n constructor() {\n super(\n 'The `access` page prop is missing. Configure `accessControl` on '\n + 'AuthModule.forRootAsync() — @stratal/framework shares it automatically '\n + 'on every Inertia render once access control is enabled.',\n )\n this.name = 'MissingAccessPropsError'\n }\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport type { Check, Permission, RoleName, SharedAccess } from '../../access/types'\nimport { evaluate, matchesPermission, matchesRole } from './match'\nimport { MissingAccessPropsError } from './missing-access-props.error'\n\ninterface AccessPageProps extends PageProps {\n access?: SharedAccess\n}\n\n/**\n * Returns the current user's roles and merged permissions.\n *\n * @throws {MissingAccessPropsError} when the page carries no `access` prop.\n */\nexport function useAccess(): SharedAccess {\n const access = usePage<AccessPageProps>().props.access\n if (!access) throw new MissingAccessPropsError()\n return access\n}\n\n/**\n * Whether the current user holds the given permission(s).\n *\n * @example\n * ```ts\n * const canEdit = useCan('posts:update')\n * const canEither = useCan({ any: ['posts:update', 'posts:delete'] })\n * const canBoth = useCan({ all: ['posts:read', 'admin:access'] })\n * ```\n */\nexport function useCan(check: Check<Permission>): boolean {\n return evaluate(useAccess(), check as Check<string>, matchesPermission)\n}\n\n/**\n * Whether the current user holds the given role(s).\n *\n * @example\n * ```ts\n * const isAdmin = useRole('admin')\n * const isStaff = useRole({ any: ['editor', 'reviewer'] })\n * ```\n */\nexport function useRole(check: Check<RoleName>): boolean {\n return evaluate(useAccess(), check as Check<string>, matchesRole)\n}\n","import type { ReactNode } from 'react'\nimport type { Check, Permission, RoleName } from '../../access/types'\nimport { useCan, useRole } from './use-access'\n\n/**\n * Exactly one of the three forms, enforced by the union: supplying two at once\n * is a compile error rather than a precedence rule to remember.\n */\ntype GateProps<TKey extends string, TValue> =\n | (Record<TKey, TValue> & { any?: never; all?: never; children?: ReactNode })\n | ({ any: readonly TValue[] } & Partial<Record<TKey, never>> & { all?: never; children?: ReactNode })\n | ({ all: readonly TValue[] } & Partial<Record<TKey, never>> & { any?: never; children?: ReactNode })\n\nexport type CanProps = GateProps<'do', Permission>\nexport type RoleProps = GateProps<'is', RoleName>\n\nfunction toCheck<TValue>(props: Record<string, unknown>, key: string): Check<TValue> {\n // `props.any !== undefined` rather than `'any' in props`: `GateProps` types the\n // off-branches as `any?: never`, which types-check an explicit `any: undefined`\n // (e.g. a spread from a conditional). `in` would still treat that branch as\n // chosen and hand `evaluate()` an `undefined` list to call `.some()` on.\n if (props.any !== undefined) return { any: props.any as readonly TValue[] }\n if (props.all !== undefined) return { all: props.all as readonly TValue[] }\n return props[key] as Check<TValue>\n}\n\n/**\n * Renders its children when the current user holds the permission(s).\n *\n * @example\n * ```ts\n * <Can do=\"posts:update\"><EditButton /></Can>\n * <Can any={['posts:update', 'posts:delete']}><Toolbar /></Can>\n * <Can all={['posts:read', 'admin:access']}><AuditLog /></Can>\n * ```\n */\nexport function Can(props: CanProps): ReactNode {\n return useCan(toCheck<Permission>(props, 'do')) ? props.children : null\n}\n\n/** Renders its children when the current user does **not** hold the permission(s). */\nexport function Cannot(props: CanProps): ReactNode {\n return useCan(toCheck<Permission>(props, 'do')) ? null : props.children\n}\n\n/**\n * Renders its children when the current user holds the role(s).\n *\n * @example\n * ```ts\n * <HasRole is=\"admin\"><AdminPanel /></HasRole>\n * <HasRole any={['editor', 'reviewer']}><ReviewQueue /></HasRole>\n * ```\n */\nexport function HasRole(props: RoleProps): ReactNode {\n return useRole(toCheck<RoleName>(props, 'is')) ? props.children : null\n}\n\n/** Renders its children when the current user holds **none** of the role(s). */\nexport function HasNoRole(props: RoleProps): ReactNode {\n return useRole(toCheck<RoleName>(props, 'is')) ? null : props.children\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,SAAgB,kBAAkB,QAAsB,YAA6B;CACnF,MAAM,QAAQ,WAAW,QAAQ,GAAG;CACpC,MAAM,WAAW,UAAU,KAAK,aAAa,WAAW,MAAM,GAAG,KAAK;CACtE,MAAM,SAAS,UAAU,KAAK,MAAM,WAAW,MAAM,QAAQ,CAAC;CAE9D,MAAM,UAAU,OAAO,YAAY;CACnC,IAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,OAAO;CAE7C,OAAO,WAAW,OAAO,QAAQ,SAAS,MAAM;AAClD;;AAGA,SAAgB,YAAY,QAAsB,MAAuB;CACvE,OAAO,OAAO,MAAM,SAAS,IAAI;AACnC;;AAGA,SAAgB,SACd,QACA,OACA,SACS;CACT,IAAI,OAAO,UAAU,UAAU,OAAO,QAAQ,QAAQ,KAAK;CAC3D,IAAI,SAAS,OAAO,OAAO,MAAM,IAAI,MAAM,UAAU,QAAQ,QAAQ,KAAK,CAAC;CAC3E,OAAO,MAAM,IAAI,OAAO,UAAU,QAAQ,QAAQ,KAAK,CAAC;AAC1D;;;;;;;;;;;;ACjCA,IAAa,0BAAb,cAA6C,MAAM;CACjD,cAAc;EACZ,MACE,gMAGF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;ACHA,SAAgB,YAA0B;CACxC,MAAM,SAAS,QAAyB,CAAC,CAAC,MAAM;CAChD,IAAI,CAAC,QAAQ,MAAM,IAAI,wBAAwB;CAC/C,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,OAAO,OAAmC;CACxD,OAAO,SAAS,UAAU,GAAG,OAAwB,iBAAiB;AACxE;;;;;;;;;;AAWA,SAAgB,QAAQ,OAAiC;CACvD,OAAO,SAAS,UAAU,GAAG,OAAwB,WAAW;AAClE;;;AC9BA,SAAS,QAAgB,OAAgC,KAA4B;CAKnF,IAAI,MAAM,QAAQ,KAAA,GAAW,OAAO,EAAE,KAAK,MAAM,IAAyB;CAC1E,IAAI,MAAM,QAAQ,KAAA,GAAW,OAAO,EAAE,KAAK,MAAM,IAAyB;CAC1E,OAAO,MAAM;AACf;;;;;;;;;;;AAYA,SAAgB,IAAI,OAA4B;CAC9C,OAAO,OAAO,QAAoB,OAAO,IAAI,CAAC,IAAI,MAAM,WAAW;AACrE;;AAGA,SAAgB,OAAO,OAA4B;CACjD,OAAO,OAAO,QAAoB,OAAO,IAAI,CAAC,IAAI,OAAO,MAAM;AACjE;;;;;;;;;;AAWA,SAAgB,QAAQ,OAA6B;CACnD,OAAO,QAAQ,QAAkB,OAAO,IAAI,CAAC,IAAI,MAAM,WAAW;AACpE;;AAGA,SAAgB,UAAU,OAA6B;CACrD,OAAO,QAAQ,QAAkB,OAAO,IAAI,CAAC,IAAI,OAAO,MAAM;AAChE"}
@@ -0,0 +1,20 @@
1
+ //#region src/react-dom-server-legacy-stub.d.ts
2
+ /**
3
+ * Removes react-dom's legacy synchronous server renderer from the worker SSR bundle.
4
+ *
5
+ * `react-dom/server` (workerd → `server.edge.js`) `require()`s two independent
6
+ * builds: the streaming `react-dom-server.edge` (exposes `renderToReadableStream`,
7
+ * the only renderer Stratal uses — see `ssr.ts`) and the synchronous
8
+ * `react-dom-server-legacy.browser` (`renderToString` / `renderToStaticMarkup`).
9
+ * The CJS `require` defeats tree-shaking, so the unused legacy build ships ~200 KB
10
+ * raw / ~40 KB gzip of dead code. The `stratalInertia()` Vite plugin aliases the
11
+ * legacy build to this module so it never reaches the worker.
12
+ *
13
+ * Stratal renders exclusively with `renderToReadableStream`; the synchronous
14
+ * renderer is not supported in the worker. These exports exist only because
15
+ * `server.edge.js` reads them at load time — calling them throws.
16
+ */
17
+ export declare const renderToString: () => never;
18
+ export declare const renderToStaticMarkup: () => never;
19
+ //#endregion
20
+ //# sourceMappingURL=react-dom-server-legacy-stub.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-dom-server-legacy-stub.d.mts","names":[],"sources":["../src/react-dom-server-legacy-stub.ts"],"mappings":";;;;;;;;;;;;;;;;qBAuBa;qBACA"}
@@ -0,0 +1,25 @@
1
+ //#region src/react-dom-server-legacy-stub.ts
2
+ /**
3
+ * Removes react-dom's legacy synchronous server renderer from the worker SSR bundle.
4
+ *
5
+ * `react-dom/server` (workerd → `server.edge.js`) `require()`s two independent
6
+ * builds: the streaming `react-dom-server.edge` (exposes `renderToReadableStream`,
7
+ * the only renderer Stratal uses — see `ssr.ts`) and the synchronous
8
+ * `react-dom-server-legacy.browser` (`renderToString` / `renderToStaticMarkup`).
9
+ * The CJS `require` defeats tree-shaking, so the unused legacy build ships ~200 KB
10
+ * raw / ~40 KB gzip of dead code. The `stratalInertia()` Vite plugin aliases the
11
+ * legacy build to this module so it never reaches the worker.
12
+ *
13
+ * Stratal renders exclusively with `renderToReadableStream`; the synchronous
14
+ * renderer is not supported in the worker. These exports exist only because
15
+ * `server.edge.js` reads them at load time — calling them throws.
16
+ */
17
+ function removed(api) {
18
+ throw new Error(`[@stratal/inertia] react-dom/server.${api} is not available in the worker SSR build — Stratal renders with renderToReadableStream.`);
19
+ }
20
+ const renderToString = () => removed("renderToString");
21
+ const renderToStaticMarkup = () => removed("renderToStaticMarkup");
22
+ //#endregion
23
+ export { renderToStaticMarkup, renderToString };
24
+
25
+ //# sourceMappingURL=react-dom-server-legacy-stub.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-dom-server-legacy-stub.mjs","names":[],"sources":["../src/react-dom-server-legacy-stub.ts"],"sourcesContent":["/**\n * Removes react-dom's legacy synchronous server renderer from the worker SSR bundle.\n *\n * `react-dom/server` (workerd → `server.edge.js`) `require()`s two independent\n * builds: the streaming `react-dom-server.edge` (exposes `renderToReadableStream`,\n * the only renderer Stratal uses — see `ssr.ts`) and the synchronous\n * `react-dom-server-legacy.browser` (`renderToString` / `renderToStaticMarkup`).\n * The CJS `require` defeats tree-shaking, so the unused legacy build ships ~200 KB\n * raw / ~40 KB gzip of dead code. The `stratalInertia()` Vite plugin aliases the\n * legacy build to this module so it never reaches the worker.\n *\n * Stratal renders exclusively with `renderToReadableStream`; the synchronous\n * renderer is not supported in the worker. These exports exist only because\n * `server.edge.js` reads them at load time — calling them throws.\n */\n\nfunction removed(api: string): never {\n throw new Error(\n `[@stratal/inertia] react-dom/server.${api} is not available in the worker SSR build — ` +\n `Stratal renders with renderToReadableStream.`,\n )\n}\n\nexport const renderToString = (): never => removed('renderToString')\nexport const renderToStaticMarkup = (): never => removed('renderToStaticMarkup')\n"],"mappings":";;;;;;;;;;;;;;;;AAgBA,SAAS,QAAQ,KAAoB;CACnC,MAAM,IAAI,MACR,uCAAuC,IAAI,yFAE7C;AACF;AAEA,MAAa,uBAA8B,QAAQ,gBAAgB;AACnE,MAAa,6BAAoC,QAAQ,sBAAsB"}
package/dist/react.d.mts CHANGED
@@ -1,9 +1,8 @@
1
1
  /// <reference path="../global.d.ts" />
2
2
  import { t as SeoData } from "./types-DzE1pdZs.mjs";
3
- import { g as InertiaTranslationKeys } from "./types-BhgXhWx6.mjs";
3
+ import { T as InertiaTranslationKeys } from "./types-BltKoOR7.mjs";
4
4
  import { CurrentRoute, RouteMatcher, RouteName, RouteParams } from "stratal/router";
5
5
  import { MessageParams } from "stratal/i18n";
6
-
7
6
  //#region src/react/seo.d.ts
8
7
  /**
9
8
  * Returns the resolved SEO data shared by the backend for the current page.
@@ -12,10 +11,10 @@ import { MessageParams } from "stratal/i18n";
12
11
  * initial paint + the auto-injected client runtime on navigation); use this
13
12
  * hook only when you want to read the metadata inside a component.
14
13
  */
15
- declare function useSeo(): SeoData;
14
+ export declare function useSeo(): SeoData;
16
15
  //#endregion
17
16
  //#region src/react/use-i18n.d.ts
18
- declare function useI18n(): {
17
+ export declare function useI18n(): {
19
18
  t: (key: InertiaTranslationKeys, params?: MessageParams) => string;
20
19
  locale: string;
21
20
  };
@@ -58,7 +57,7 @@ declare function useI18n(): {
58
57
  * }
59
58
  * ```
60
59
  */
61
- declare function useRoute(): {
60
+ export declare function useRoute(): {
62
61
  route: <N extends RouteName>(name: N, params?: RouteParams<N>) => string;
63
62
  current: {
64
63
  (): RouteName | null;
@@ -68,5 +67,4 @@ declare function useRoute(): {
68
67
  params: Record<string, string>;
69
68
  };
70
69
  //#endregion
71
- export { useI18n, useRoute, useSeo };
72
70
  //# sourceMappingURL=react.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/seo.ts","../src/react/use-i18n.ts","../src/react/use-route.ts"],"mappings":";;;;;;;;;;;AAeA;;iBAAgB,MAAA,IAAU,OAAO;;;iBCHjB,OAAA;WAUC,sBAAA,EAAsB,MAAA,GAAW,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCmN/C,QAAA;oBAKK,SAAA,EAAS,IAAA,EAAQ,CAAA,EAAC,MAAA,GAAW,WAAA,CAAY,CAAA;;QAOvC,SAAA;IAAA,OACG,YAAA;EAAA"}
1
+ {"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/seo.ts","../src/react/use-i18n.ts","../src/react/use-route.ts"],"mappings":";;;;;;;;;;;;wBAegB,UAAU;;;wBCHV;EAUC,IAAA,KAAA,wBAAsB,SAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBCmNlC;EAKL,QAAA,UAAU,WAAS,MAAQ,GAAC,SAAW,YAAY;;QAOvC;KACG,MAAA"}
package/dist/react.mjs CHANGED
@@ -109,7 +109,7 @@ function buildUrl(route, name, params, localeConfig) {
109
109
  * query string of an unrelated route.
110
110
  */
111
111
  function filterCarryover(carryover, route) {
112
- const allowed = new Set([...route.paramNames, ...route.domainParamNames]);
112
+ const allowed = /* @__PURE__ */ new Set([...route.paramNames, ...route.domainParamNames]);
113
113
  if (route.localePaths?.length) allowed.add("locale");
114
114
  if (allowed.size === 0) return {};
115
115
  const filtered = {};
@@ -1 +1 @@
1
- {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/seo.ts","../src/react/use-i18n.ts","../src/react/use-route.ts"],"sourcesContent":["import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport type { SeoData } from '../seo/types'\n\ninterface SeoPageProps extends PageProps {\n seo?: SeoData\n}\n\n/**\n * Returns the resolved SEO data shared by the backend for the current page.\n *\n * The document head is kept in sync automatically (server injection on the\n * initial paint + the auto-injected client runtime on navigation); use this\n * hook only when you want to read the metadata inside a component.\n */\nexport function useSeo(): SeoData {\n return usePage<SeoPageProps>().props.seo ?? {}\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport IntlMessageFormat from 'intl-messageformat'\nimport { useMemo } from 'react'\nimport type { MessageParams } from 'stratal/i18n'\nimport type { InertiaTranslationKeys } from '../types'\n\ninterface I18nPageProps extends PageProps {\n locale: string\n translations: Record<string, string>\n}\n\nexport function useI18n() {\n const { locale, translations } = usePage<I18nPageProps>().props\n\n const t = useMemo(() => {\n const compiled = new Map<string, IntlMessageFormat>()\n\n for (const [key, value] of Object.entries(translations)) {\n compiled.set(key, new IntlMessageFormat(value, locale))\n }\n\n return (key: InertiaTranslationKeys, params?: MessageParams): string => {\n const msg = compiled.get(key)\n if (!msg) return key\n return String(msg.format(params as Record<string, string | number | boolean>))\n }\n }, [locale, translations])\n\n return { t, locale }\n}\n","/**\n * React hook for Ziggy-like client-side URL generation.\n *\n * Reads serialized routes and the current request's matched-route snapshot\n * (injected by the `routes` option on {@link InertiaModuleOptions}) and\n * provides a type-safe `route()` function that mirrors the server-side\n * `buildRouteUrl()`, plus `current()` and `params` for current-route\n * introspection.\n *\n * @module\n */\n\nimport type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport { useMemo } from 'react'\nimport type { CurrentRoute, LocaleUrlConfig, RouteMatcher, RouteName, RouteParams, SerializedRoute, SerializedRoutes, TrailingSlashMode } from 'stratal/router'\n\ninterface RoutesPageProps extends PageProps {\n routes: SerializedRoutes\n trailingSlash?: TrailingSlashMode\n route: CurrentRoute\n localeConfig?: LocaleUrlConfig\n}\n\n/**\n * Apply a trailing-slash mode to a URL or path.\n *\n * Pure reimplementation of `applyTrailingSlash()` from `stratal/router` —\n * mirrored here to keep the React bundle decoupled from server-only deps.\n *\n * - `'ignore'` — return as-is.\n * - `'always'` — append `/` unless path is root or last segment is file-like (`.json`, etc.).\n * - `'never'` — strip a single trailing `/` from the pathname (skip root).\n *\n * Preserves query string and hash. Handles relative paths and absolute URLs.\n */\nexport function applyTrailingSlash(url: string, mode: TrailingSlashMode): string {\n if (mode === 'ignore') return url\n\n const isAbsolute = /^https?:\\/\\//i.test(url)\n const parsed = isAbsolute ? new URL(url) : new URL(url, 'http://placeholder.local')\n const path = parsed.pathname\n if (path === '/') return url\n const hasTrailing = path.endsWith('/')\n\n if (mode === 'always' && !hasTrailing) {\n const lastSegment = path.slice(path.lastIndexOf('/') + 1)\n if (lastSegment.includes('.')) return url\n parsed.pathname = `${path}/`\n } else if (mode === 'never' && hasTrailing) {\n parsed.pathname = path.slice(0, -1)\n } else {\n return url\n }\n\n return isAbsolute\n ? parsed.toString()\n : `${parsed.pathname}${parsed.search}${parsed.hash}`\n}\n\n/**\n * Encode a path-param value while preserving forward slashes so catch-all\n * params (`:slug{.+}`) round-trip cleanly. Mirrors the server-side\n * `encodePathParam()` in `stratal/router`.\n */\nfunction encodePathParam(value: string): string {\n return value.split('/').map(encodeURIComponent).join('/')\n}\n\n/**\n * Build a URL from a serialized route definition.\n *\n * Mirrors `buildRouteUrl()` from `stratal/router` (pure reimplementation to\n * avoid pulling server-side dependencies into the browser bundle).\n */\nfunction buildUrl(route: SerializedRoute, name: string, params?: Record<string, string>, localeConfig?: LocaleUrlConfig): string {\n const allParams = { ...params }\n const consumedKeys = new Set<string>()\n let url = route.path\n\n if (allParams.locale && route.localePaths?.length) {\n const shouldPrefix = !localeConfig\n || localeConfig.prefixDefaultLocale === true\n || allParams.locale !== localeConfig.defaultLocale\n if (shouldPrefix) {\n url = `/${allParams.locale}${url === '/' ? '' : url}`\n }\n consumedKeys.add('locale')\n }\n\n for (const paramName of route.paramNames) {\n const value = allParams[paramName]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${paramName}\" for route \"${name}\" (path: ${route.path})`)\n }\n url = url.replace(\n new RegExp(`:${paramName}(\\\\{[^}]*\\\\})?`),\n encodePathParam(value),\n )\n consumedKeys.add(paramName)\n }\n\n let domain: string | undefined\n if (route.domain) {\n domain = route.domain\n for (const domainParam of route.domainParamNames) {\n const value = allParams[domainParam]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${domainParam}\" for route \"${name}\" (domain: ${route.domain})`)\n }\n domain = domain.replace(`{${domainParam}}`, encodeURIComponent(value))\n consumedKeys.add(domainParam)\n }\n }\n\n const queryEntries = Object.entries(allParams).filter(([key]) => !consumedKeys.has(key))\n if (queryEntries.length > 0) {\n const queryString = queryEntries\n .filter(([, v]) => Boolean(v))\n .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)\n .join('&')\n url = `${url}${queryString.length ? `?${queryString}` : ''}`\n }\n\n if (domain) {\n url = `https://${domain}${url}`\n }\n\n return url\n}\n\n/**\n * Filter a param bag down to the keys the target route actually declares —\n * so a `companyId` carried over from the current URL never leaks into the\n * query string of an unrelated route.\n */\nfunction filterCarryover(carryover: Record<string, string>, route: SerializedRoute): Record<string, string> {\n const allowed = new Set<string>([...route.paramNames, ...route.domainParamNames])\n if (route.localePaths?.length) allowed.add('locale')\n if (allowed.size === 0) return {}\n\n const filtered: Record<string, string> = {}\n for (const [key, value] of Object.entries(carryover)) {\n if (allowed.has(key)) filtered[key] = value\n }\n return filtered\n}\n\n/**\n * Pure URL resolver. Mirrors what {@link useRoute}'s `route()` does, but\n * without React — exposed for testing and for non-hook callers.\n *\n * Merges params in order (last wins): sticky `defaults`, current-route\n * carryover (filtered to the target's declared params), explicit params.\n */\nexport function resolveUrl<N extends RouteName>(\n name: N,\n explicitParams: RouteParams<N> | undefined,\n routes: SerializedRoutes,\n currentRoute: CurrentRoute,\n trailingSlash: TrailingSlashMode = 'ignore',\n localeConfig?: LocaleUrlConfig,\n): string {\n const target = routes[name]\n if (!target) {\n throw new Error(`Route \"${name}\" not found.`)\n }\n\n const merged = {\n ...currentRoute.defaults,\n ...filterCarryover(currentRoute.params, target),\n ...explicitParams,\n } as Record<string, string>\n\n return applyTrailingSlash(buildUrl(target, name, merged, localeConfig), trailingSlash)\n}\n\n/**\n * Pure overload signatures for {@link matchCurrent} / `useRoute().current()`.\n *\n * - No arg → matched route name (or `null`).\n * - With a name → `true`/`false`. Strict-typed: only real route names and\n * dotted wildcard prefixes (`'users.*'`) are accepted.\n */\nexport function matchCurrent(currentRoute: CurrentRoute): RouteName | null\nexport function matchCurrent(currentRoute: CurrentRoute, name: RouteMatcher): boolean\nexport function matchCurrent(currentRoute: CurrentRoute, name?: RouteMatcher): RouteName | null | boolean {\n if (name === undefined) return currentRoute.name\n if (currentRoute.name === null) return false\n if (typeof name === 'string' && name.endsWith('.*')) {\n const prefix = name.slice(0, -1)\n return currentRoute.name.startsWith(prefix)\n }\n return currentRoute.name === name\n}\n\n/**\n * Hook that provides Ziggy-like route URL generation in React components.\n *\n * Consumes `routes` and the current-request snapshot (`route`) from Inertia\n * shared props. Route names and params are strictly typed from\n * `StratalRouteMap` (generated by `quarry route:types`).\n *\n * Requires the `routes` option to be set on `InertiaModule.forRoot()`.\n *\n * Sticky params — anything in `defaults` (set server-side via `Uri.defaults()`)\n * and anything in the current route's extracted `params` (filtered to the\n * target route's declared params) — are merged into every `route()` call.\n * Explicit params always win.\n *\n * @returns\n * - `route(name, params?)` — URL builder\n * - `current()` / `current(name)` — matched route name (or wildcard match)\n * - `params` — extracted params for the current request URL\n *\n * @example\n * ```tsx\n * import { useRoute } from '@stratal/inertia/react'\n *\n * export default function UserProfile({ user }) {\n * const { route, current, currentRoute } = useRoute()\n *\n * return (\n * <nav>\n * <a href={route('users.index')}>All Users</a>\n * <a href={route('users.show', { id: user.id })}>{user.name}</a>\n * {current('users.*') && <span>On a users page</span>}\n * {currentRoute.name === 'users.show' && <span>#{currentRoute.params.id}</span>}\n * </nav>\n * )\n * }\n * ```\n */\nexport function useRoute() {\n const page = usePage<RoutesPageProps>()\n const { routes, trailingSlash = 'ignore', route: currentRoute, localeConfig } = page.props\n\n const route = useMemo(\n () => <N extends RouteName>(name: N, params?: RouteParams<N>): string =>\n resolveUrl(name, params, routes, currentRoute, trailingSlash, localeConfig),\n [routes, trailingSlash, currentRoute, localeConfig],\n )\n\n const current = useMemo(\n () => {\n function impl(): RouteName | null\n function impl(name: RouteMatcher): boolean\n function impl(name?: RouteMatcher): RouteName | null | boolean {\n return name === undefined ? matchCurrent(currentRoute) : matchCurrent(currentRoute, name)\n }\n return impl\n },\n [currentRoute],\n )\n\n return { route, current, currentRoute, params: currentRoute.params }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/seo.ts","../src/react/use-i18n.ts","../src/react/use-route.ts"],"sourcesContent":["import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport type { SeoData } from '../seo/types'\n\ninterface SeoPageProps extends PageProps {\n seo?: SeoData\n}\n\n/**\n * Returns the resolved SEO data shared by the backend for the current page.\n *\n * The document head is kept in sync automatically (server injection on the\n * initial paint + the auto-injected client runtime on navigation); use this\n * hook only when you want to read the metadata inside a component.\n */\nexport function useSeo(): SeoData {\n return usePage<SeoPageProps>().props.seo ?? {}\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport IntlMessageFormat from 'intl-messageformat'\nimport { useMemo } from 'react'\nimport type { MessageParams } from 'stratal/i18n'\nimport type { InertiaTranslationKeys } from '../types'\n\ninterface I18nPageProps extends PageProps {\n locale: string\n translations: Record<string, string>\n}\n\nexport function useI18n() {\n const { locale, translations } = usePage<I18nPageProps>().props\n\n const t = useMemo(() => {\n const compiled = new Map<string, IntlMessageFormat>()\n\n for (const [key, value] of Object.entries(translations)) {\n compiled.set(key, new IntlMessageFormat(value, locale))\n }\n\n return (key: InertiaTranslationKeys, params?: MessageParams): string => {\n const msg = compiled.get(key)\n if (!msg) return key\n return String(msg.format(params))\n }\n }, [locale, translations])\n\n return { t, locale }\n}\n","/**\n * React hook for Ziggy-like client-side URL generation.\n *\n * Reads serialized routes and the current request's matched-route snapshot\n * (injected by the `routes` option on {@link InertiaModuleOptions}) and\n * provides a type-safe `route()` function that mirrors the server-side\n * `buildRouteUrl()`, plus `current()` and `params` for current-route\n * introspection.\n *\n * @module\n */\n\nimport type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport { useMemo } from 'react'\nimport type { CurrentRoute, LocaleUrlConfig, RouteMatcher, RouteName, RouteParams, SerializedRoute, SerializedRoutes, TrailingSlashMode } from 'stratal/router'\n\ninterface RoutesPageProps extends PageProps {\n routes: SerializedRoutes\n trailingSlash?: TrailingSlashMode\n route: CurrentRoute\n localeConfig?: LocaleUrlConfig\n}\n\n/**\n * Apply a trailing-slash mode to a URL or path.\n *\n * Pure reimplementation of `applyTrailingSlash()` from `stratal/router` —\n * mirrored here to keep the React bundle decoupled from server-only deps.\n *\n * - `'ignore'` — return as-is.\n * - `'always'` — append `/` unless path is root or last segment is file-like (`.json`, etc.).\n * - `'never'` — strip a single trailing `/` from the pathname (skip root).\n *\n * Preserves query string and hash. Handles relative paths and absolute URLs.\n */\nexport function applyTrailingSlash(url: string, mode: TrailingSlashMode): string {\n if (mode === 'ignore') return url\n\n const isAbsolute = /^https?:\\/\\//i.test(url)\n const parsed = isAbsolute ? new URL(url) : new URL(url, 'http://placeholder.local')\n const path = parsed.pathname\n if (path === '/') return url\n const hasTrailing = path.endsWith('/')\n\n if (mode === 'always' && !hasTrailing) {\n const lastSegment = path.slice(path.lastIndexOf('/') + 1)\n if (lastSegment.includes('.')) return url\n parsed.pathname = `${path}/`\n } else if (mode === 'never' && hasTrailing) {\n parsed.pathname = path.slice(0, -1)\n } else {\n return url\n }\n\n return isAbsolute\n ? parsed.toString()\n : `${parsed.pathname}${parsed.search}${parsed.hash}`\n}\n\n/**\n * Encode a path-param value while preserving forward slashes so catch-all\n * params (`:slug{.+}`) round-trip cleanly. Mirrors the server-side\n * `encodePathParam()` in `stratal/router`.\n */\nfunction encodePathParam(value: string): string {\n return value.split('/').map(encodeURIComponent).join('/')\n}\n\n/**\n * Build a URL from a serialized route definition.\n *\n * Mirrors `buildRouteUrl()` from `stratal/router` (pure reimplementation to\n * avoid pulling server-side dependencies into the browser bundle).\n */\nfunction buildUrl(route: SerializedRoute, name: string, params?: Record<string, string>, localeConfig?: LocaleUrlConfig): string {\n const allParams = { ...params }\n const consumedKeys = new Set<string>()\n let url = route.path\n\n if (allParams.locale && route.localePaths?.length) {\n const shouldPrefix = !localeConfig\n || localeConfig.prefixDefaultLocale === true\n || allParams.locale !== localeConfig.defaultLocale\n if (shouldPrefix) {\n url = `/${allParams.locale}${url === '/' ? '' : url}`\n }\n consumedKeys.add('locale')\n }\n\n for (const paramName of route.paramNames) {\n const value = allParams[paramName]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${paramName}\" for route \"${name}\" (path: ${route.path})`)\n }\n url = url.replace(\n new RegExp(`:${paramName}(\\\\{[^}]*\\\\})?`),\n encodePathParam(value),\n )\n consumedKeys.add(paramName)\n }\n\n let domain: string | undefined\n if (route.domain) {\n domain = route.domain\n for (const domainParam of route.domainParamNames) {\n const value = allParams[domainParam]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${domainParam}\" for route \"${name}\" (domain: ${route.domain})`)\n }\n domain = domain.replace(`{${domainParam}}`, encodeURIComponent(value))\n consumedKeys.add(domainParam)\n }\n }\n\n const queryEntries = Object.entries(allParams).filter(([key]) => !consumedKeys.has(key))\n if (queryEntries.length > 0) {\n const queryString = queryEntries\n .filter(([, v]) => Boolean(v))\n .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)\n .join('&')\n url = `${url}${queryString.length ? `?${queryString}` : ''}`\n }\n\n if (domain) {\n url = `https://${domain}${url}`\n }\n\n return url\n}\n\n/**\n * Filter a param bag down to the keys the target route actually declares —\n * so a `companyId` carried over from the current URL never leaks into the\n * query string of an unrelated route.\n */\nfunction filterCarryover(carryover: Record<string, string>, route: SerializedRoute): Record<string, string> {\n const allowed = new Set<string>([...route.paramNames, ...route.domainParamNames])\n if (route.localePaths?.length) allowed.add('locale')\n if (allowed.size === 0) return {}\n\n const filtered: Record<string, string> = {}\n for (const [key, value] of Object.entries(carryover)) {\n if (allowed.has(key)) filtered[key] = value\n }\n return filtered\n}\n\n/**\n * Pure URL resolver. Mirrors what {@link useRoute}'s `route()` does, but\n * without React — exposed for testing and for non-hook callers.\n *\n * Merges params in order (last wins): sticky `defaults`, current-route\n * carryover (filtered to the target's declared params), explicit params.\n */\nexport function resolveUrl<N extends RouteName>(\n name: N,\n explicitParams: RouteParams<N> | undefined,\n routes: SerializedRoutes,\n currentRoute: CurrentRoute,\n trailingSlash: TrailingSlashMode = 'ignore',\n localeConfig?: LocaleUrlConfig,\n): string {\n const target = routes[name]\n if (!target) {\n throw new Error(`Route \"${name}\" not found.`)\n }\n\n const merged = {\n ...currentRoute.defaults,\n ...filterCarryover(currentRoute.params, target),\n ...explicitParams,\n } as Record<string, string>\n\n return applyTrailingSlash(buildUrl(target, name, merged, localeConfig), trailingSlash)\n}\n\n/**\n * Pure overload signatures for {@link matchCurrent} / `useRoute().current()`.\n *\n * - No arg → matched route name (or `null`).\n * - With a name → `true`/`false`. Strict-typed: only real route names and\n * dotted wildcard prefixes (`'users.*'`) are accepted.\n */\nexport function matchCurrent(currentRoute: CurrentRoute): RouteName | null\nexport function matchCurrent(currentRoute: CurrentRoute, name: RouteMatcher): boolean\nexport function matchCurrent(currentRoute: CurrentRoute, name?: RouteMatcher): RouteName | null | boolean {\n if (name === undefined) return currentRoute.name\n if (currentRoute.name === null) return false\n if (typeof name === 'string' && name.endsWith('.*')) {\n const prefix = name.slice(0, -1)\n return currentRoute.name.startsWith(prefix)\n }\n return currentRoute.name === name\n}\n\n/**\n * Hook that provides Ziggy-like route URL generation in React components.\n *\n * Consumes `routes` and the current-request snapshot (`route`) from Inertia\n * shared props. Route names and params are strictly typed from\n * `StratalRouteMap` (generated by `quarry route:types`).\n *\n * Requires the `routes` option to be set on `InertiaModule.forRoot()`.\n *\n * Sticky params — anything in `defaults` (set server-side via `Uri.defaults()`)\n * and anything in the current route's extracted `params` (filtered to the\n * target route's declared params) — are merged into every `route()` call.\n * Explicit params always win.\n *\n * @returns\n * - `route(name, params?)` — URL builder\n * - `current()` / `current(name)` — matched route name (or wildcard match)\n * - `params` — extracted params for the current request URL\n *\n * @example\n * ```tsx\n * import { useRoute } from '@stratal/inertia/react'\n *\n * export default function UserProfile({ user }) {\n * const { route, current, currentRoute } = useRoute()\n *\n * return (\n * <nav>\n * <a href={route('users.index')}>All Users</a>\n * <a href={route('users.show', { id: user.id })}>{user.name}</a>\n * {current('users.*') && <span>On a users page</span>}\n * {currentRoute.name === 'users.show' && <span>#{currentRoute.params.id}</span>}\n * </nav>\n * )\n * }\n * ```\n */\nexport function useRoute() {\n const page = usePage<RoutesPageProps>()\n const { routes, trailingSlash = 'ignore', route: currentRoute, localeConfig } = page.props\n\n const route = useMemo(\n () => <N extends RouteName>(name: N, params?: RouteParams<N>): string =>\n resolveUrl(name, params, routes, currentRoute, trailingSlash, localeConfig),\n [routes, trailingSlash, currentRoute, localeConfig],\n )\n\n const current = useMemo(\n () => {\n function impl(): RouteName | null\n function impl(name: RouteMatcher): boolean\n function impl(name?: RouteMatcher): RouteName | null | boolean {\n return name === undefined ? matchCurrent(currentRoute) : matchCurrent(currentRoute, name)\n }\n return impl\n },\n [currentRoute],\n )\n\n return { route, current, currentRoute, params: currentRoute.params }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- export { };
1
+ export {}
@@ -31,9 +31,9 @@ function applySeoToHead(seo, doc = document) {
31
31
  //#endregion
32
32
  //#region src/seo-runtime.ts
33
33
  /**
34
- * Client-side SEO head sync. Side-effect module: importing it registers a
35
- * single Inertia `navigate` listener that reconciles `document.head` from the
36
- * shared `seo` prop on every SPA visit.
34
+ * Client-side SEO head sync. Side-effect module: importing it registers Inertia
35
+ * listeners that reconcile `document.head` from the shared `seo` prop on every
36
+ * SPA visit.
37
37
  *
38
38
  * Consumers never import this directly — the `stratalInertia()` Vite plugin
39
39
  * injects it into the client entry, so backend `ctx.seo()` metadata stays in
@@ -44,11 +44,13 @@ const INSTALLED_KEY = "__stratalInertiaSeoInstalled";
44
44
  const globalScope = globalThis;
45
45
  if (!globalScope[INSTALLED_KEY]) {
46
46
  globalScope[INSTALLED_KEY] = true;
47
- router.on("navigate", (event) => {
48
- const props = event.detail.page.props;
47
+ const reconcile = (page) => {
48
+ const props = page.props;
49
49
  if (!("seo" in props)) return;
50
50
  applySeoToHead(props.seo ?? {});
51
- });
51
+ };
52
+ router.on("navigate", (event) => reconcile(event.detail.page));
53
+ router.on("success", (event) => reconcile(event.detail.page));
52
54
  }
53
55
  //#endregion
54
56
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"seo-runtime.mjs","names":[],"sources":["../src/seo/apply-seo-to-head.ts","../src/seo-runtime.ts"],"sourcesContent":["/// <reference lib=\"dom\" />\nimport { DATA_SEO_ATTR, buildSeoTags } from './build-seo-tags'\nimport type { SeoData } from './types'\n\n/**\n * Reconciles `document.head` with the resolved SEO data (client-side).\n *\n * Removes only the previously managed `[data-seo]` tags and re-creates them\n * from {@link buildSeoTags}; the title is applied via `doc.title` so the single\n * `<title>` element is updated in place rather than duplicated, and the\n * {@link DATA_SEO_ATTR} marker is re-stamped on it so the next reconcile finds\n * and replaces it instead of leaving a stale title behind.\n *\n * Pure and DOM-only (no React) so it can be unit-tested under jsdom.\n */\nexport function applySeoToHead(seo: SeoData, doc: Document = document): void {\n const head = doc.head\n // Remove only previously SEO-managed tags; unmanaged head content is untouched.\n head.querySelectorAll(`[${DATA_SEO_ATTR}]`).forEach((el) => el.remove())\n\n for (const descriptor of buildSeoTags(seo)) {\n if (descriptor.tag === 'title') {\n // `doc.title` updates the single <title> in place. Re-stamp the marker so\n // the element is tracked as managed and replaced on the next navigation.\n doc.title = descriptor.content ?? ''\n doc.head.querySelector('title')?.setAttribute(DATA_SEO_ATTR, '')\n continue\n }\n const el = doc.createElement(descriptor.tag)\n for (const [key, value] of Object.entries(descriptor.attrs)) {\n // A single malformed attribute name must not abort the reconcile and\n // leave the head half-updated. `setAttribute` throws on invalid names,\n // so isolate each one and skip the offending attribute only.\n try {\n el.setAttribute(key, value)\n } catch {\n // Invalid attribute name — drop this attribute, keep building the tag.\n }\n }\n head.appendChild(el)\n }\n}\n","/**\n * Client-side SEO head sync. Side-effect module: importing it registers a\n * single Inertia `navigate` listener that reconciles `document.head` from the\n * shared `seo` prop on every SPA visit.\n *\n * Consumers never import this directly — the `stratalInertia()` Vite plugin\n * injects it into the client entry, so backend `ctx.seo()` metadata stays in\n * sync across navigations with zero app wiring. The server still injects the\n * tags for the initial paint; this only runs on subsequent client visits.\n */\nimport { router } from '@inertiajs/core'\nimport { applySeoToHead } from './seo/apply-seo-to-head'\nimport type { SeoData } from './seo/types'\n\n// Guard against duplicate registration when the module is re-evaluated (e.g.\n// dev-server HMR, or the runtime injected into more than one client entry).\nconst INSTALLED_KEY = '__stratalInertiaSeoInstalled'\nconst globalScope = globalThis as Record<string, unknown>\n\nif (!globalScope[INSTALLED_KEY]) {\n globalScope[INSTALLED_KEY] = true\n router.on('navigate', (event) => {\n const props = event.detail.page.props as { seo?: SeoData }\n // The backend shares `seo` as an always-evaluated prop, so it is present on\n // every response — including partial reloads. Only reconcile the head when\n // the key is actually present; never act on a guessed-empty value, which\n // would wipe managed tags a partial reload didn't intend to touch.\n if (!('seo' in props)) return\n applySeoToHead(props.seo ?? {})\n })\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,eAAe,KAAc,MAAgB,UAAgB;CAC3E,MAAM,OAAO,IAAI;CAEjB,KAAK,iBAAiB,IAAI,cAAc,EAAE,EAAE,SAAS,OAAO,GAAG,OAAO,CAAC;CAEvE,KAAK,MAAM,cAAc,aAAa,GAAG,GAAG;EAC1C,IAAI,WAAW,QAAQ,SAAS;GAG9B,IAAI,QAAQ,WAAW,WAAW;GAClC,IAAI,KAAK,cAAc,OAAO,GAAG,aAAa,eAAe,EAAE;GAC/D;EACF;EACA,MAAM,KAAK,IAAI,cAAc,WAAW,GAAG;EAC3C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,WAAW,KAAK,GAIxD,IAAI;GACF,GAAG,aAAa,KAAK,KAAK;EAC5B,QAAQ,CAER;EAEF,KAAK,YAAY,EAAE;CACrB;AACF;;;;;;;;;;;;;ACzBA,MAAM,gBAAgB;AACtB,MAAM,cAAc;AAEpB,IAAI,CAAC,YAAY,gBAAgB;CAC/B,YAAY,iBAAiB;CAC7B,OAAO,GAAG,aAAa,UAAU;EAC/B,MAAM,QAAQ,MAAM,OAAO,KAAK;EAKhC,IAAI,EAAE,SAAS,QAAQ;EACvB,eAAe,MAAM,OAAO,CAAC,CAAC;CAChC,CAAC;AACH"}
1
+ {"version":3,"file":"seo-runtime.mjs","names":[],"sources":["../src/seo/apply-seo-to-head.ts","../src/seo-runtime.ts"],"sourcesContent":["/// <reference lib=\"dom\" />\nimport { DATA_SEO_ATTR, buildSeoTags } from './build-seo-tags'\nimport type { SeoData } from './types'\n\n/**\n * Reconciles `document.head` with the resolved SEO data (client-side).\n *\n * Removes only the previously managed `[data-seo]` tags and re-creates them\n * from {@link buildSeoTags}; the title is applied via `doc.title` so the single\n * `<title>` element is updated in place rather than duplicated, and the\n * {@link DATA_SEO_ATTR} marker is re-stamped on it so the next reconcile finds\n * and replaces it instead of leaving a stale title behind.\n *\n * Pure and DOM-only (no React) so it can be unit-tested under jsdom.\n */\nexport function applySeoToHead(seo: SeoData, doc: Document = document): void {\n const head = doc.head\n // Remove only previously SEO-managed tags; unmanaged head content is untouched.\n head.querySelectorAll(`[${DATA_SEO_ATTR}]`).forEach((el) => el.remove())\n\n for (const descriptor of buildSeoTags(seo)) {\n if (descriptor.tag === 'title') {\n // `doc.title` updates the single <title> in place. Re-stamp the marker so\n // the element is tracked as managed and replaced on the next navigation.\n doc.title = descriptor.content ?? ''\n doc.head.querySelector('title')?.setAttribute(DATA_SEO_ATTR, '')\n continue\n }\n const el = doc.createElement(descriptor.tag)\n for (const [key, value] of Object.entries(descriptor.attrs)) {\n // A single malformed attribute name must not abort the reconcile and\n // leave the head half-updated. `setAttribute` throws on invalid names,\n // so isolate each one and skip the offending attribute only.\n try {\n el.setAttribute(key, value)\n } catch {\n // Invalid attribute name — drop this attribute, keep building the tag.\n }\n }\n head.appendChild(el)\n }\n}\n","/**\n * Client-side SEO head sync. Side-effect module: importing it registers Inertia\n * listeners that reconcile `document.head` from the shared `seo` prop on every\n * SPA visit.\n *\n * Consumers never import this directly — the `stratalInertia()` Vite plugin\n * injects it into the client entry, so backend `ctx.seo()` metadata stays in\n * sync across navigations with zero app wiring. The server still injects the\n * tags for the initial paint; this only runs on subsequent client visits.\n */\nimport { router } from '@inertiajs/core'\nimport { applySeoToHead } from './seo/apply-seo-to-head'\nimport type { SeoData } from './seo/types'\n\n// Guard against duplicate registration when the module is re-evaluated (e.g.\n// dev-server HMR, or the runtime injected into more than one client entry).\nconst INSTALLED_KEY = '__stratalInertiaSeoInstalled'\nconst globalScope = globalThis as Record<string, unknown>\n\nif (!globalScope[INSTALLED_KEY]) {\n globalScope[INSTALLED_KEY] = true\n\n const reconcile = (page: { props: Record<string, unknown> }): void => {\n const props = page.props as { seo?: SeoData }\n // The backend shares `seo` as an always-evaluated prop, so it is present on\n // every response — including partial reloads. Only reconcile the head when\n // the key is actually present; never act on a guessed-empty value, which\n // would wipe managed tags a partial reload didn't intend to touch.\n if (!('seo' in props)) return\n applySeoToHead(props.seo ?? {})\n }\n\n // A history entry being entered — including Back and Forward, which fetch nothing and so\n // report no success of their own.\n router.on('navigate', (event) => reconcile(event.detail.page))\n\n // A visit that only changed the props of the component already on screen reports `success`\n // and no `navigate`. Closing a modal is exactly that: it lands on the page the level was\n // drawn over, which is already rendered — so on navigate alone the head kept the level's\n // title while the address had moved back to the page's. Reconciling is idempotent, so the\n // visits that fire both settle on the same head twice rather than fighting.\n router.on('success', (event) => reconcile(event.detail.page))\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,eAAe,KAAc,MAAgB,UAAgB;CAC3E,MAAM,OAAO,IAAI;CAEjB,KAAK,iBAAiB,IAAI,cAAc,EAAE,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,CAAC;CAEvE,KAAK,MAAM,cAAc,aAAa,GAAG,GAAG;EAC1C,IAAI,WAAW,QAAQ,SAAS;GAG9B,IAAI,QAAQ,WAAW,WAAW;GAClC,IAAI,KAAK,cAAc,OAAO,CAAC,EAAE,aAAa,eAAe,EAAE;GAC/D;EACF;EACA,MAAM,KAAK,IAAI,cAAc,WAAW,GAAG;EAC3C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,WAAW,KAAK,GAIxD,IAAI;GACF,GAAG,aAAa,KAAK,KAAK;EAC5B,QAAQ,CAER;EAEF,KAAK,YAAY,EAAE;CACrB;AACF;;;;;;;;;;;;;ACzBA,MAAM,gBAAgB;AACtB,MAAM,cAAc;AAEpB,IAAI,CAAC,YAAY,gBAAgB;CAC/B,YAAY,iBAAiB;CAE7B,MAAM,aAAa,SAAmD;EACpE,MAAM,QAAQ,KAAK;EAKnB,IAAI,EAAE,SAAS,QAAQ;EACvB,eAAe,MAAM,OAAO,CAAC,CAAC;CAChC;CAIA,OAAO,GAAG,aAAa,UAAU,UAAU,MAAM,OAAO,IAAI,CAAC;CAO7D,OAAO,GAAG,YAAY,UAAU,UAAU,MAAM,OAAO,IAAI,CAAC;AAC9D"}
@@ -0,0 +1,38 @@
1
+ //#region src/services/ssr-exclusion.d.ts
2
+ /**
3
+ * Build-time ↔ runtime contract for excluding pages from server-side rendering.
4
+ *
5
+ * The `stratalInertia()` Vite plugin's `ssrExclude` option lists page-component
6
+ * globs that must not be server-rendered. At build time the plugin (a) injects an
7
+ * `ignore` into the SSR `import.meta.glob` so those page modules never enter the
8
+ * worker bundle, and (b) defines {@link SSR_EXCLUDE_GLOBAL} with the same glob
9
+ * list. At runtime {@link InertiaService} reads that global and renders matching
10
+ * components client-only — there is no SSR bundle entry left to render them from.
11
+ *
12
+ * Patterns match Inertia component names — the `import.meta.glob` key with the
13
+ * `./pages/` prefix and `.tsx` suffix stripped, e.g. `Admin/Dashboard`:
14
+ * - `*` matches a single path segment (no `/`)
15
+ * - `**` matches any number of segments (including `/`)
16
+ * - every other character is matched literally
17
+ */
18
+ export declare const SSR_EXCLUDE_GLOBAL = "__STRATAL_INERTIA_SSR_EXCLUDE__";
19
+ /**
20
+ * Read the SSR-exclusion glob list injected by the Vite plugin. Returns an empty
21
+ * list when `ssrExclude` is not configured, so every page server-renders.
22
+ */
23
+ export declare function readSsrExcludePatterns(): string[];
24
+ /** Compile component-name globs into anchored matchers (see module docs for syntax). */
25
+ export declare function compileSsrExcludePatterns(patterns: string[]): RegExp[];
26
+ /** True when a component name matches any compiled exclusion matcher. */
27
+ export declare function isSsrExcluded(component: string, matchers: RegExp[]): boolean;
28
+ /**
29
+ * The compiled exclusion matchers for this isolate. The pattern list is static —
30
+ * the Vite plugin defines it at build time — so the matchers are compiled once
31
+ * and shared across requests rather than rebuilt in every `@Request`-scoped
32
+ * {@link InertiaService}.
33
+ */
34
+ export declare function getSsrExcludeMatchers(): RegExp[];
35
+ /** Test-only: drop the memoized matchers so a new {@link SSR_EXCLUDE_GLOBAL} value takes effect. */
36
+ export declare function resetSsrExcludeMatchers(): void;
37
+ //#endregion
38
+ //# sourceMappingURL=ssr-exclusion.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr-exclusion.d.mts","names":[],"sources":["../../src/services/ssr-exclusion.ts"],"mappings":";;;;;;;;;;;;;;;;;qBAgBa;;;;;wBAUG;;wBAKA,0BAA0B,qBAAqB;;wBAY/C,cAAc,mBAAmB,UAAU;;;;;;;wBAY3C,yBAAyB;;wBAKzB"}
Binary file
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr-exclusion.mjs","names":[],"sources":["../../src/services/ssr-exclusion.ts"],"sourcesContent":["/**\n * Build-time ↔ runtime contract for excluding pages from server-side rendering.\n *\n * The `stratalInertia()` Vite plugin's `ssrExclude` option lists page-component\n * globs that must not be server-rendered. At build time the plugin (a) injects an\n * `ignore` into the SSR `import.meta.glob` so those page modules never enter the\n * worker bundle, and (b) defines {@link SSR_EXCLUDE_GLOBAL} with the same glob\n * list. At runtime {@link InertiaService} reads that global and renders matching\n * components client-only — there is no SSR bundle entry left to render them from.\n *\n * Patterns match Inertia component names — the `import.meta.glob` key with the\n * `./pages/` prefix and `.tsx` suffix stripped, e.g. `Admin/Dashboard`:\n * - `*` matches a single path segment (no `/`)\n * - `**` matches any number of segments (including `/`)\n * - every other character is matched literally\n */\nexport const SSR_EXCLUDE_GLOBAL = '__STRATAL_INERTIA_SSR_EXCLUDE__'\n\ninterface SsrExcludeGlobal {\n __STRATAL_INERTIA_SSR_EXCLUDE__?: string[]\n}\n\n/**\n * Read the SSR-exclusion glob list injected by the Vite plugin. Returns an empty\n * list when `ssrExclude` is not configured, so every page server-renders.\n */\nexport function readSsrExcludePatterns(): string[] {\n return (globalThis as SsrExcludeGlobal).__STRATAL_INERTIA_SSR_EXCLUDE__ ?? []\n}\n\n/** Compile component-name globs into anchored matchers (see module docs for syntax). */\nexport function compileSsrExcludePatterns(patterns: string[]): RegExp[] {\n return patterns.map((pattern) => {\n const source = pattern\n .replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\*\\*/g, '\u0000')\n .replace(/\\*/g, '[^/]*')\n .replace(/\u0000/g, '.*')\n return new RegExp(`^${source}$`)\n })\n}\n\n/** True when a component name matches any compiled exclusion matcher. */\nexport function isSsrExcluded(component: string, matchers: RegExp[]): boolean {\n return matchers.some((matcher) => matcher.test(component))\n}\n\nlet cachedMatchers: RegExp[] | null = null\n\n/**\n * The compiled exclusion matchers for this isolate. The pattern list is static —\n * the Vite plugin defines it at build time — so the matchers are compiled once\n * and shared across requests rather than rebuilt in every `@Request`-scoped\n * {@link InertiaService}.\n */\nexport function getSsrExcludeMatchers(): RegExp[] {\n return (cachedMatchers ??= compileSsrExcludePatterns(readSsrExcludePatterns()))\n}\n\n/** Test-only: drop the memoized matchers so a new {@link SSR_EXCLUDE_GLOBAL} value takes effect. */\nexport function resetSsrExcludeMatchers(): void {\n cachedMatchers = null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAa,qBAAqB;;;;;AAUlC,SAAgB,yBAAmC;CACjD,OAAQ,WAAgC,mCAAmC,CAAC;AAC9E;;AAGA,SAAgB,0BAA0B,UAA8B;CACtE,OAAO,SAAS,KAAK,YAAY;EAC/B,MAAM,SAAS,QACZ,QAAQ,sBAAsB,MAAM,CAAC,CACrC,QAAQ,SAAS,IAAG,CAAC,CACrB,QAAQ,OAAO,OAAO,CAAC,CACvB,QAAQ,MAAM,IAAI;EACrB,OAAO,IAAI,OAAO,IAAI,OAAO,EAAE;CACjC,CAAC;AACH;;AAGA,SAAgB,cAAc,WAAmB,UAA6B;CAC5E,OAAO,SAAS,MAAM,YAAY,QAAQ,KAAK,SAAS,CAAC;AAC3D;AAEA,IAAI,iBAAkC;;;;;;;AAQtC,SAAgB,wBAAkC;CAChD,OAAQ,mBAAmB,0BAA0B,uBAAuB,CAAC;AAC/E;;AAGA,SAAgB,0BAAgC;CAC9C,iBAAiB;AACnB"}
package/dist/ssr.d.mts CHANGED
@@ -1,7 +1,6 @@
1
- import { h as InertiaSsrResult } from "./types-BhgXhWx6.mjs";
1
+ import { w as InertiaSsrResult } from "./types-BltKoOR7.mjs";
2
2
  import { ComponentType, ReactNode } from "react";
3
3
  import { HeadManagerTitleCallback, Page } from "@inertiajs/core";
4
-
5
4
  //#region src/ssr.d.ts
6
5
  /**
7
6
  * The props Inertia's `App` component receives, reconstructed locally from
@@ -25,7 +24,12 @@ type ResolvedPage<TProps> = ComponentType<TProps> | {
25
24
  * `import.meta.glob` yields — and with one it is the typed component/module.
26
25
  */
27
26
  type ResolverReturn<TProps> = [unknown] extends [TProps] ? unknown : ResolvedPage<TProps> | Promise<ResolvedPage<TProps>>;
28
- interface CreateInertiaSsrAppOptions<TProps = unknown> {
27
+ /**
28
+ * The declared options of {@link createInertiaSsrApp}, before `prepare`'s
29
+ * presence requirement is applied. Every inference site for `TProps` and
30
+ * `TPrepared` lives here, in plain (non-conditional) positions.
31
+ */
32
+ export interface InertiaSsrAppOptions<TProps = unknown, TPrepared = undefined> {
29
33
  /**
30
34
  * Resolve a page by name. Typically backed by `import.meta.glob`, whose modules
31
35
  * are opaque (`unknown`) — the returned value is unwrapped (a `default` export is
@@ -34,21 +38,47 @@ interface CreateInertiaSsrAppOptions<TProps = unknown> {
34
38
  * argument to {@link createInertiaSsrApp} to type the resolver's return.
35
39
  */
36
40
  resolve: (name: string) => ResolverReturn<NoInfer<TProps>>;
41
+ /**
42
+ * Compute a per-render value before the tree is built, and receive it back in
43
+ * `setup`. Runs once per `render(page)` call. Required whenever `setup` expects
44
+ * a `prepared` other than `undefined` — see {@link CreateInertiaSsrAppOptions}.
45
+ *
46
+ * This exists so a request-scoped value — resolved modal components, a request
47
+ * logger — can reach the tree without a module-level variable. A worker isolate
48
+ * serves many requests concurrently and interleaves them at every await, so a
49
+ * module-level "current request" value is a cross-request leak, not a shortcut.
50
+ */
51
+ prepare?: (page: Page) => TPrepared | Promise<TPrepared>;
37
52
  /**
38
53
  * Optional wrapper for application-level providers (theme, store, i18n, …).
39
- * Receives the Inertia `App` component and its props; return the React tree to
40
- * render. When omitted, `App` is rendered directly.
54
+ * Receives the Inertia `App` component, its props, and this render's `prepare`
55
+ * result. Return the React tree to render. When omitted, `App` is rendered
56
+ * directly.
41
57
  */
42
58
  setup?: (args: {
43
59
  App: ComponentType<AppProps>;
44
60
  props: AppProps;
61
+ prepared: TPrepared;
45
62
  }) => ReactNode;
46
63
  /**
47
64
  * Optional document-title callback (Inertia `title`), applied to page titles.
48
65
  */
49
66
  title?: HeadManagerTitleCallback;
50
67
  }
51
- interface InertiaSsrApp {
68
+ /**
69
+ * Options for {@link createInertiaSsrApp}.
70
+ *
71
+ * `TPrepared` is inferred from `prepare`'s return type *and* from an annotated
72
+ * `prepared` on `setup`. The intersected member closes the gap between the two:
73
+ * unless `TPrepared` is `undefined`, `prepare` becomes required, so a `setup`
74
+ * that claims a `prepared` nothing produces fails to compile instead of reading
75
+ * `undefined` at runtime. The requirement is expressed as an intersection rather
76
+ * than a union of two option shapes because a union is discriminated only by
77
+ * literal-valued properties — `prepare` holds a function, so a union would leave
78
+ * `setup`'s parameters without a contextual type.
79
+ */
80
+ export type CreateInertiaSsrAppOptions<TProps = unknown, TPrepared = undefined> = InertiaSsrAppOptions<TProps, TPrepared> & ([undefined] extends [TPrepared] ? unknown : Pick<Required<InertiaSsrAppOptions<TProps, TPrepared>>, 'prepare'>);
81
+ export interface InertiaSsrApp {
52
82
  render(page: Page): Promise<InertiaSsrResult>;
53
83
  }
54
84
  /**
@@ -59,7 +89,6 @@ interface InertiaSsrApp {
59
89
  * progressively. Head tags rendered inside a *suspended* boundary are not
60
90
  * captured; use Stratal's server-side SEO (`ctx.seo()`) for `<head>` metadata.
61
91
  */
62
- declare function createInertiaSsrApp<TProps = unknown>(options: CreateInertiaSsrAppOptions<TProps>): InertiaSsrApp;
92
+ export declare function createInertiaSsrApp<TProps = unknown, TPrepared = undefined>(options: CreateInertiaSsrAppOptions<TProps, TPrepared>): InertiaSsrApp;
63
93
  //#endregion
64
- export { CreateInertiaSsrAppOptions, InertiaSsrApp, createInertiaSsrApp };
65
94
  //# sourceMappingURL=ssr.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.d.mts","names":[],"sources":["../src/ssr.ts"],"mappings":";;;;;;;;;;UAqCU,QAAA;EACR,WAAA,EAAa,IAAA;EAKb,gBAAA,GAAmB,aAAA;EAEnB,gBAAA,IAAoB,IAAA,UAAc,IAAA,GAAO,IAAA,KAAS,aAAA,QAAqB,OAAA,CAAQ,aAAA;EAC/E,aAAA,GAAgB,wBAAA;EAChB,YAAA,IAAgB,QAAA;AAAA;;KAIb,YAAA,WAAuB,aAAA,CAAc,MAAA;EAAY,OAAA,EAAS,aAAA,CAAc,MAAA;AAAA;;;AAJzC;AAAA;;KAW/B,cAAA,8BAA4C,MAAA,cAE7C,YAAA,CAAa,MAAA,IAAU,OAAA,CAAQ,YAAA,CAAa,MAAA;AAAA,UAkB/B,0BAAA;EA3BW;;;;;;;EAqC1B,OAAA,GAAU,IAAA,aAAiB,cAAA,CAAe,OAAA,CAAQ,MAAA;EArCE;;;;AAA6B;EA2CjF,KAAA,IAAS,IAAA;IAAQ,GAAA,EAAK,aAAA,CAAc,QAAA;IAAW,KAAA,EAAO,QAAA;EAAA,MAAe,SAAA;EAlCtD;;;EAsCf,KAAA,GAAQ,wBAAA;AAAA;AAAA,UAGO,aAAA;EACf,MAAA,CAAO,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,gBAAA;AAAA;;;;;;;;AA1CwB;iBAqDtC,mBAAA,mBACd,OAAA,EAAS,0BAAA,CAA2B,MAAA,IACnC,aAAA"}
1
+ {"version":3,"file":"ssr.d.mts","names":[],"sources":["../src/ssr.ts"],"mappings":";;;;;;;;;UAqCU;EACR,aAAa;EAKb,mBAAmB;EAEnB,oBAAoB,cAAc,OAAO,SAAS,qBAAqB,QAAQ;EAC/E,gBAAgB;EAChB,gBAAgB;;;KAIb,aAAa,UAAU,cAAc;EAAY,SAAS,cAAc;;;;;;;KAOxE,eAAe,6BAA6B,oBAE7C,aAAa,UAAU,QAAQ,aAAa;;;;;;iBAuB/B,qBAAqB,kBAAkB;;;;;;;;EAUtD,UAAU,iBAAiB,eAAe,QAAQ;;;;;;;;;;;EAWlD,WAAW,MAAM,SAAS,YAAY,QAAQ;;;;;;;EAW9C,SAAS;IAAQ,KAAK,cAAc;IAAW,OAAO;IAAU,UAAU;QAAgB;;;;EAI1F,QAAQ;;;;;;;;;;;;;;YAeE,2BAA2B,kBAAkB,yBACvD,qBAAqB,QAAQ,mCACL,uBAAuB,KAAK,SAAS,qBAAqB,QAAQ;iBAE3E;EACf,OAAO,MAAM,OAAO,QAAQ;;;;;;;;;;wBAWd,oBAAoB,kBAAkB,uBACpD,SAAS,2BAA2B,QAAQ,aAC3C"}
package/dist/ssr.mjs CHANGED
@@ -31,19 +31,22 @@ function createInertiaSsrApp(options) {
31
31
  });
32
32
  return { async render(page) {
33
33
  let head = [];
34
+ const [initialComponent, prepared] = await Promise.all([resolveComponent(page.component), Promise.resolve(options.prepare?.(page))]);
34
35
  const props = {
35
36
  initialPage: page,
36
- initialComponent: await resolveComponent(page.component),
37
+ initialComponent,
37
38
  resolveComponent,
38
39
  titleCallback: options.title,
39
40
  onHeadUpdate: (elements) => {
40
41
  head = elements;
41
42
  }
42
43
  };
43
- const stream = await renderToReadableStream(options.setup ? options.setup({
44
+ const app = options.setup ? options.setup({
44
45
  App,
45
- props
46
- }) : createElement(App, props));
46
+ props,
47
+ prepared
48
+ }) : createElement(App, props);
49
+ const stream = await renderToReadableStream(app);
47
50
  return {
48
51
  head,
49
52
  stream
package/dist/ssr.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.mjs","names":[],"sources":["../src/ssr.ts"],"sourcesContent":["/**\n * Server-side rendering entry for Stratal Inertia.\n *\n * Provides {@link createInertiaSsrApp}, which encapsulates React 19 streaming SSR\n * (`renderToReadableStream`) and Inertia's head collection, returning the\n * `render(page)` function the `InertiaModule` SSR bundle option expects.\n *\n * This entry pulls React + `react-dom/server` into the worker SSR bundle and is\n * intentionally separate from the client (`./react`) and server (`.`) entries.\n *\n * @packageDocumentation\n */\n\nimport type { HeadManagerTitleCallback, Page } from '@inertiajs/core'\n// Import `App` as a runtime value only — never reference its *type*\n// (`typeof App`, `Parameters<typeof App>`, `ComponentProps<typeof App>`, …) in\n// this module's exported surface. Any such reference makes the emitted `.d.mts`\n// re-export `import { App } from '@inertiajs/react'`, which pulls Inertia's whole\n// type graph into resolution the moment a consumer imports this SSR entry. That\n// eagerly evaluates `@inertiajs/core`'s config-driven types (`FlashData`,\n// `SharedPageProps`, derived from its `InertiaConfig` interface) before a\n// consumer's own `declare module '@inertiajs/core'` augmentation has been\n// applied, caching the un-augmented defaults — so `usePage().flash` /\n// `usePage().props` degrade to `unknown` at call sites. Typing this entry's\n// surface structurally (below) keeps `@inertiajs/react` out of the generated\n// declarations and avoids the hazard.\nimport { App } from '@inertiajs/react'\nimport { type ComponentType, type ReactNode, createElement } from 'react'\nimport { renderToReadableStream } from 'react-dom/server'\nimport { ApplicationError } from 'stratal/errors'\nimport type { InertiaSsrResult } from './types'\n\n/**\n * The props Inertia's `App` component receives, reconstructed locally from\n * `@inertiajs/core` + React types. Mirrors `@inertiajs/react`'s `InertiaAppProps`\n * without importing it — see the `App` import note above for why that matters.\n */\ninterface AppProps {\n initialPage: Page\n // `ComponentType<any>` mirrors Inertia's own `ReactComponent` (page components\n // are resolved opaquely), keeping the resolver's `ComponentType<TProps>` output\n // assignable here without coupling to `@inertiajs/react`'s exported types.\n // oxlint-disable-next-line typescript/no-explicit-any\n initialComponent?: ComponentType<any>\n // oxlint-disable-next-line typescript/no-explicit-any\n resolveComponent?: (name: string, page?: Page) => ComponentType<any> | Promise<ComponentType<any>>\n titleCallback?: HeadManagerTitleCallback\n onHeadUpdate?: (elements: string[]) => void\n}\n\n/** A page component for `TProps`, or a module namespace whose `default` is one. */\ntype ResolvedPage<TProps> = ComponentType<TProps> | { default: ComponentType<TProps> }\n\n/**\n * The resolver's return type, keyed on whether a props type argument was supplied:\n * with none (`TProps` defaults to `unknown`) it stays opaque — matching what\n * `import.meta.glob` yields — and with one it is the typed component/module.\n */\ntype ResolverReturn<TProps> = [unknown] extends [TProps]\n ? unknown\n : ResolvedPage<TProps> | Promise<ResolvedPage<TProps>>\n\n/** Unwrap a module namespace's `default` export, leaving a bare component as-is. */\nfunction unwrapDefault(module: unknown): unknown {\n return typeof module === 'object' && module !== null && 'default' in module\n ? (module as { default: unknown }).default\n : module\n}\n\n/**\n * A React component is either a function (function/class component) or an object\n * (a `memo`/`forwardRef`/`lazy` exotic component). This narrows the opaque value a\n * dynamic import yields without admitting `any`.\n */\nfunction isPageComponent<TProps>(value: unknown): value is ComponentType<TProps> {\n return typeof value === 'function' || (typeof value === 'object' && value !== null)\n}\n\nexport interface CreateInertiaSsrAppOptions<TProps = unknown> {\n /**\n * Resolve a page by name. Typically backed by `import.meta.glob`, whose modules\n * are opaque (`unknown`) — the returned value is unwrapped (a `default` export is\n * taken when present) and narrowed to a component at runtime, so an invalid\n * resolver result fails loudly rather than rendering nothing. Pass a props type\n * argument to {@link createInertiaSsrApp} to type the resolver's return.\n */\n // `NoInfer` keeps `TProps` pinned to its explicit type argument (or the\n // `unknown` default) instead of being widened back out of the resolver return.\n resolve: (name: string) => ResolverReturn<NoInfer<TProps>>\n /**\n * Optional wrapper for application-level providers (theme, store, i18n, …).\n * Receives the Inertia `App` component and its props; return the React tree to\n * render. When omitted, `App` is rendered directly.\n */\n setup?: (args: { App: ComponentType<AppProps>; props: AppProps }) => ReactNode\n /**\n * Optional document-title callback (Inertia `title`), applied to page titles.\n */\n title?: HeadManagerTitleCallback\n}\n\nexport interface InertiaSsrApp {\n render(page: Page): Promise<InertiaSsrResult>\n}\n\n/**\n * Build a streaming Inertia SSR handler.\n *\n * The returned `render(page)` resolves once React's shell is ready — at which\n * point Inertia's `<Head>` tags have been collected — and streams the body\n * progressively. Head tags rendered inside a *suspended* boundary are not\n * captured; use Stratal's server-side SEO (`ctx.seo()`) for `<head>` metadata.\n */\nexport function createInertiaSsrApp<TProps = unknown>(\n options: CreateInertiaSsrAppOptions<TProps>,\n): InertiaSsrApp {\n const resolveComponent = (name: string): Promise<ComponentType<TProps>> =>\n Promise.resolve(options.resolve(name)).then((module) => {\n const component = unwrapDefault(module)\n if (!isPageComponent<TProps>(component)) {\n throw new ApplicationError(`[stratal:inertia] resolve(\"${name}\") did not return a React component.`)\n }\n return component\n })\n\n return {\n async render(page: Page): Promise<InertiaSsrResult> {\n let head: string[] = []\n const initialComponent = await resolveComponent(page.component)\n const props: AppProps = {\n initialPage: page,\n initialComponent,\n resolveComponent,\n titleCallback: options.title,\n onHeadUpdate: (elements: string[]) => { head = elements },\n }\n const app = options.setup\n ? options.setup({ App, props })\n : createElement(App, props)\n const stream = await renderToReadableStream(app)\n return { head, stream }\n },\n }\n}\n"],"mappings":";;;;;;AA+DA,SAAS,cAAc,QAA0B;CAC/C,OAAO,OAAO,WAAW,YAAY,WAAW,QAAQ,aAAa,SAChE,OAAgC,UACjC;AACN;;;;;;AAOA,SAAS,gBAAwB,OAAgD;CAC/E,OAAO,OAAO,UAAU,cAAe,OAAO,UAAU,YAAY,UAAU;AAChF;;;;;;;;;AAqCA,SAAgB,oBACd,SACe;CACf,MAAM,oBAAoB,SACxB,QAAQ,QAAQ,QAAQ,QAAQ,IAAI,CAAC,EAAE,MAAM,WAAW;EACtD,MAAM,YAAY,cAAc,MAAM;EACtC,IAAI,CAAC,gBAAwB,SAAS,GACpC,MAAM,IAAI,iBAAiB,8BAA8B,KAAK,qCAAqC;EAErG,OAAO;CACT,CAAC;CAEH,OAAO,EACL,MAAM,OAAO,MAAuC;EAClD,IAAI,OAAiB,CAAC;EAEtB,MAAM,QAAkB;GACtB,aAAa;GACb,kBAAA,MAH6B,iBAAiB,KAAK,SAAS;GAI5D;GACA,eAAe,QAAQ;GACvB,eAAe,aAAuB;IAAE,OAAO;GAAS;EAC1D;EAIA,MAAM,SAAS,MAAM,uBAHT,QAAQ,QAChB,QAAQ,MAAM;GAAE;GAAK;EAAM,CAAC,IAC5B,cAAc,KAAK,KAAK,CACmB;EAC/C,OAAO;GAAE;GAAM;EAAO;CACxB,EACF;AACF"}
1
+ {"version":3,"file":"ssr.mjs","names":[],"sources":["../src/ssr.ts"],"sourcesContent":["/**\n * Server-side rendering entry for Stratal Inertia.\n *\n * Provides {@link createInertiaSsrApp}, which encapsulates React 19 streaming SSR\n * (`renderToReadableStream`) and Inertia's head collection, returning the\n * `render(page)` function the `InertiaModule` SSR bundle option expects.\n *\n * This entry pulls React + `react-dom/server` into the worker SSR bundle and is\n * intentionally separate from the client (`./react`) and server (`.`) entries.\n *\n * @packageDocumentation\n */\n\nimport type { HeadManagerTitleCallback, Page } from '@inertiajs/core'\n// Import `App` as a runtime value only — never reference its *type*\n// (`typeof App`, `Parameters<typeof App>`, `ComponentProps<typeof App>`, …) in\n// this module's exported surface. Any such reference makes the emitted `.d.mts`\n// re-export `import { App } from '@inertiajs/react'`, which pulls Inertia's whole\n// type graph into resolution the moment a consumer imports this SSR entry. That\n// eagerly evaluates `@inertiajs/core`'s config-driven types (`FlashData`,\n// `SharedPageProps`, derived from its `InertiaConfig` interface) before a\n// consumer's own `declare module '@inertiajs/core'` augmentation has been\n// applied, caching the un-augmented defaults — so `usePage().flash` /\n// `usePage().props` degrade to `unknown` at call sites. Typing this entry's\n// surface structurally (below) keeps `@inertiajs/react` out of the generated\n// declarations and avoids the hazard.\nimport { App } from '@inertiajs/react'\nimport { type ComponentType, type ReactNode, createElement } from 'react'\nimport { renderToReadableStream } from 'react-dom/server'\nimport { ApplicationError } from 'stratal/errors'\nimport type { InertiaSsrResult } from './types'\n\n/**\n * The props Inertia's `App` component receives, reconstructed locally from\n * `@inertiajs/core` + React types. Mirrors `@inertiajs/react`'s `InertiaAppProps`\n * without importing it — see the `App` import note above for why that matters.\n */\ninterface AppProps {\n initialPage: Page\n // `ComponentType<any>` mirrors Inertia's own `ReactComponent` (page components\n // are resolved opaquely), keeping the resolver's `ComponentType<TProps>` output\n // assignable here without coupling to `@inertiajs/react`'s exported types.\n // oxlint-disable-next-line typescript/no-explicit-any\n initialComponent?: ComponentType<any>\n // oxlint-disable-next-line typescript/no-explicit-any\n resolveComponent?: (name: string, page?: Page) => ComponentType<any> | Promise<ComponentType<any>>\n titleCallback?: HeadManagerTitleCallback\n onHeadUpdate?: (elements: string[]) => void\n}\n\n/** A page component for `TProps`, or a module namespace whose `default` is one. */\ntype ResolvedPage<TProps> = ComponentType<TProps> | { default: ComponentType<TProps> }\n\n/**\n * The resolver's return type, keyed on whether a props type argument was supplied:\n * with none (`TProps` defaults to `unknown`) it stays opaque — matching what\n * `import.meta.glob` yields — and with one it is the typed component/module.\n */\ntype ResolverReturn<TProps> = [unknown] extends [TProps]\n ? unknown\n : ResolvedPage<TProps> | Promise<ResolvedPage<TProps>>\n\n/** Unwrap a module namespace's `default` export, leaving a bare component as-is. */\nfunction unwrapDefault(module: unknown): unknown {\n return typeof module === 'object' && module !== null && 'default' in module\n ? (module).default\n : module\n}\n\n/**\n * A React component is either a function (function/class component) or an object\n * (a `memo`/`forwardRef`/`lazy` exotic component). This narrows the opaque value a\n * dynamic import yields without admitting `any`.\n */\nfunction isPageComponent<TProps>(value: unknown): value is ComponentType<TProps> {\n return typeof value === 'function' || (typeof value === 'object' && value !== null)\n}\n\n/**\n * The declared options of {@link createInertiaSsrApp}, before `prepare`'s\n * presence requirement is applied. Every inference site for `TProps` and\n * `TPrepared` lives here, in plain (non-conditional) positions.\n */\nexport interface InertiaSsrAppOptions<TProps = unknown, TPrepared = undefined> {\n /**\n * Resolve a page by name. Typically backed by `import.meta.glob`, whose modules\n * are opaque (`unknown`) — the returned value is unwrapped (a `default` export is\n * taken when present) and narrowed to a component at runtime, so an invalid\n * resolver result fails loudly rather than rendering nothing. Pass a props type\n * argument to {@link createInertiaSsrApp} to type the resolver's return.\n */\n // `NoInfer` keeps `TProps` pinned to its explicit type argument (or the\n // `unknown` default) instead of being widened back out of the resolver return.\n resolve: (name: string) => ResolverReturn<NoInfer<TProps>>\n /**\n * Compute a per-render value before the tree is built, and receive it back in\n * `setup`. Runs once per `render(page)` call. Required whenever `setup` expects\n * a `prepared` other than `undefined` — see {@link CreateInertiaSsrAppOptions}.\n *\n * This exists so a request-scoped value — resolved modal components, a request\n * logger — can reach the tree without a module-level variable. A worker isolate\n * serves many requests concurrently and interleaves them at every await, so a\n * module-level \"current request\" value is a cross-request leak, not a shortcut.\n */\n prepare?: (page: Page) => TPrepared | Promise<TPrepared>\n /**\n * Optional wrapper for application-level providers (theme, store, i18n, …).\n * Receives the Inertia `App` component, its props, and this render's `prepare`\n * result. Return the React tree to render. When omitted, `App` is rendered\n * directly.\n */\n // Declared as a property with a function type, never method shorthand: only the\n // property form is checked contravariantly under `strictFunctionTypes`, so an\n // annotated `prepared` here is an inference site for `TPrepared` rather than a\n // bivariantly-accepted lie.\n setup?: (args: { App: ComponentType<AppProps>; props: AppProps; prepared: TPrepared }) => ReactNode\n /**\n * Optional document-title callback (Inertia `title`), applied to page titles.\n */\n title?: HeadManagerTitleCallback\n}\n\n/**\n * Options for {@link createInertiaSsrApp}.\n *\n * `TPrepared` is inferred from `prepare`'s return type *and* from an annotated\n * `prepared` on `setup`. The intersected member closes the gap between the two:\n * unless `TPrepared` is `undefined`, `prepare` becomes required, so a `setup`\n * that claims a `prepared` nothing produces fails to compile instead of reading\n * `undefined` at runtime. The requirement is expressed as an intersection rather\n * than a union of two option shapes because a union is discriminated only by\n * literal-valued properties — `prepare` holds a function, so a union would leave\n * `setup`'s parameters without a contextual type.\n */\nexport type CreateInertiaSsrAppOptions<TProps = unknown, TPrepared = undefined> =\n InertiaSsrAppOptions<TProps, TPrepared> &\n ([undefined] extends [TPrepared] ? unknown : Pick<Required<InertiaSsrAppOptions<TProps, TPrepared>>, 'prepare'>)\n\nexport interface InertiaSsrApp {\n render(page: Page): Promise<InertiaSsrResult>\n}\n\n/**\n * Build a streaming Inertia SSR handler.\n *\n * The returned `render(page)` resolves once React's shell is ready — at which\n * point Inertia's `<Head>` tags have been collected — and streams the body\n * progressively. Head tags rendered inside a *suspended* boundary are not\n * captured; use Stratal's server-side SEO (`ctx.seo()`) for `<head>` metadata.\n */\nexport function createInertiaSsrApp<TProps = unknown, TPrepared = undefined>(\n options: CreateInertiaSsrAppOptions<TProps, TPrepared>,\n): InertiaSsrApp {\n const resolveComponent = (name: string): Promise<ComponentType<TProps>> =>\n Promise.resolve(options.resolve(name)).then((module) => {\n const component = unwrapDefault(module)\n if (!isPageComponent<TProps>(component)) {\n throw new ApplicationError(`[stratal:inertia] resolve(\"${name}\") did not return a React component.`)\n }\n return component\n })\n\n return {\n async render(page: Page): Promise<InertiaSsrResult> {\n let head: string[] = []\n const [initialComponent, prepared] = await Promise.all([\n resolveComponent(page.component),\n // Awaiting a `TPrepared | Promise<TPrepared>` yields `Awaited<TPrepared>`,\n // which is `TPrepared` itself: inference against that union prefers the\n // `Promise<TPrepared>` constituent over the naked one, so `TPrepared` is\n // never a promise. The `?.` short-circuit only stands in for a `TPrepared`\n // of `undefined`, because the options type requires `prepare` for any\n // other `TPrepared`.\n Promise.resolve(options.prepare?.(page)) as Promise<TPrepared>,\n ])\n const props: AppProps = {\n initialPage: page,\n initialComponent,\n resolveComponent,\n titleCallback: options.title,\n onHeadUpdate: (elements: string[]) => { head = elements },\n }\n const app = options.setup\n ? options.setup({ App, props, prepared })\n : createElement(App, props)\n const stream = await renderToReadableStream(app)\n return { head, stream }\n },\n }\n}\n"],"mappings":";;;;;;AA+DA,SAAS,cAAc,QAA0B;CAC/C,OAAO,OAAO,WAAW,YAAY,WAAW,QAAQ,aAAa,SAChE,OAAQ,UACT;AACN;;;;;;AAOA,SAAS,gBAAwB,OAAgD;CAC/E,OAAO,OAAO,UAAU,cAAe,OAAO,UAAU,YAAY,UAAU;AAChF;;;;;;;;;AA0EA,SAAgB,oBACd,SACe;CACf,MAAM,oBAAoB,SACxB,QAAQ,QAAQ,QAAQ,QAAQ,IAAI,CAAC,CAAC,CAAC,MAAM,WAAW;EACtD,MAAM,YAAY,cAAc,MAAM;EACtC,IAAI,CAAC,gBAAwB,SAAS,GACpC,MAAM,IAAI,iBAAiB,8BAA8B,KAAK,qCAAqC;EAErG,OAAO;CACT,CAAC;CAEH,OAAO,EACL,MAAM,OAAO,MAAuC;EAClD,IAAI,OAAiB,CAAC;EACtB,MAAM,CAAC,kBAAkB,YAAY,MAAM,QAAQ,IAAI,CACrD,iBAAiB,KAAK,SAAS,GAO/B,QAAQ,QAAQ,QAAQ,UAAU,IAAI,CAAC,CACzC,CAAC;EACD,MAAM,QAAkB;GACtB,aAAa;GACb;GACA;GACA,eAAe,QAAQ;GACvB,eAAe,aAAuB;IAAE,OAAO;GAAS;EAC1D;EACA,MAAM,MAAM,QAAQ,QAChB,QAAQ,MAAM;GAAE;GAAK;GAAO;EAAS,CAAC,IACtC,cAAc,KAAK,KAAK;EAC5B,MAAM,SAAS,MAAM,uBAAuB,GAAG;EAC/C,OAAO;GAAE;GAAM;EAAO;CACxB,EACF;AACF"}
@@ -1,5 +1,4 @@
1
- import { Page, Page as InertiaPage } from "@inertiajs/core";
2
-
1
+ import { Page, Page as InertiaPage, ScrollProp } from "@inertiajs/core";
3
2
  //#region src/augment/test-response.d.ts
4
3
  declare module '@stratal/testing' {
5
4
  interface TestResponse {
@@ -23,6 +22,8 @@ declare module '@stratal/testing' {
23
22
  assertInertiaDeferredProp(prop: string, group: string): Promise<this>;
24
23
  /** Assert a prop is listed as a merge prop. */
25
24
  assertInertiaMergeProp(prop: string): Promise<this>;
25
+ /** Assert a prop carries infinite-scroll metadata, optionally matching some of its fields. */
26
+ assertInertiaScrollProp(prop: string, expected?: Partial<ScrollProp>): Promise<this>;
26
27
  /** Assert a prop is listed as a shared prop. */
27
28
  assertInertiaSharedProp(prop: string): Promise<this>;
28
29
  /** Assert the response is a successful precognition response (204 with precognition headers). */
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.mts","names":[],"sources":["../src/augment/test-response.ts"],"mappings":";;;;YAKY,YAAA;IAL+B;IAOvC,aAAA,CAAc,QAAA,IAAY,IAAA,EAAM,IAAA,YAAgB,OAAA;IAPT;IASvC,sBAAA,CAAuB,SAAA,WAAoB,OAAA;IAFK;IAIhD,iBAAA,CAAkB,IAAA,UAAc,QAAA,YAAoB,OAAA;IAAA;IAEpD,uBAAA,CAAwB,IAAA,WAAe,OAAA;IAEC;IAAxC,wBAAA,CAAyB,IAAA,WAAe,OAAA;IAIM;IAF9C,gBAAA,CAAiB,GAAA,WAAc,OAAA;IAMyB;IAJxD,oBAAA,CAAqB,OAAA,kBAAyB,OAAA;IAQP;IANvC,kBAAA,CAAmB,GAAA,UAAa,KAAA,YAAiB,OAAA;IAUoB;IARrE,yBAAA,CAA0B,IAAA,UAAc,KAAA,WAAgB,OAAA;IAQoB;IAN5E,sBAAA,CAAuB,IAAA,WAAe,OAAA;IAlBtC;IAoBA,uBAAA,CAAwB,IAAA,WAAe,OAAA;IApBb;IAsB1B,4BAAA;IAtBgD;IAwBhD,kCAAA,CAAmC,MAAA,GAAS,MAAA,mBAAyB,OAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"testing.d.mts","names":[],"sources":["../src/augment/test-response.ts"],"mappings":";;;YAKY;;IAER,cAAc,YAAY,MAAM,gBAAgB;;IAEhD,uBAAuB,oBAAoB;;IAE3C,kBAAkB,cAAc,oBAAoB;;IAEpD,wBAAwB,eAAe;;IAEvC,yBAAyB,eAAe;;IAExC,iBAAiB,cAAc;;IAE/B,qBAAqB,yBAAyB;;IAE9C,mBAAmB,aAAa,iBAAiB;;IAEjD,0BAA0B,cAAc,gBAAgB;;IAExD,uBAAuB,eAAe;;IAEtC,wBAAwB,cAAc,WAAW,QAAQ,cAAc;;IAEvE,wBAAwB,eAAe;;IAEvC;;IAEA,mCAAmC,SAAS,yBAAyB"}
package/dist/testing.mjs CHANGED
@@ -14,16 +14,21 @@ function augmentTestResponse() {
14
14
  return this;
15
15
  });
16
16
  TestResponse.macro("assertInertiaProp", async function(path, expected) {
17
- const actual = getValueAtPath((await this.json()).props, path);
17
+ const page = await this.json();
18
+ const actual = getValueAtPath(page.props, path);
18
19
  expect(actual, `Expected Inertia prop "${path}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toStrictEqual(expected);
19
20
  return this;
20
21
  });
21
22
  TestResponse.macro("assertInertiaPropExists", async function(path) {
22
- expect(hasValueAtPath((await this.json()).props, path), `Expected Inertia prop "${path}" to exist`).toBe(true);
23
+ const page = await this.json();
24
+ const exists = hasValueAtPath(page.props, path);
25
+ expect(exists, `Expected Inertia prop "${path}" to exist`).toBe(true);
23
26
  return this;
24
27
  });
25
28
  TestResponse.macro("assertInertiaPropMissing", async function(path) {
26
- expect(hasValueAtPath((await this.json()).props, path), `Expected Inertia prop "${path}" to not exist`).toBe(false);
29
+ const page = await this.json();
30
+ const exists = hasValueAtPath(page.props, path);
31
+ expect(exists, `Expected Inertia prop "${path}" to not exist`).toBe(false);
27
32
  return this;
28
33
  });
29
34
  TestResponse.macro("assertInertiaUrl", async function(url) {
@@ -42,15 +47,24 @@ function augmentTestResponse() {
42
47
  return this;
43
48
  });
44
49
  TestResponse.macro("assertInertiaDeferredProp", async function(prop, group) {
45
- expect((await this.json()).deferredProps?.[group], `Expected Inertia deferred group "${group}" to contain "${prop}"`).toContain(prop);
50
+ const page = await this.json();
51
+ expect(page.deferredProps?.[group], `Expected Inertia deferred group "${group}" to contain "${prop}"`).toContain(prop);
46
52
  return this;
47
53
  });
48
54
  TestResponse.macro("assertInertiaMergeProp", async function(prop) {
49
- expect((await this.json()).mergeProps, `Expected Inertia mergeProps to contain "${prop}"`).toContain(prop);
55
+ const page = await this.json();
56
+ expect(page.mergeProps, `Expected Inertia mergeProps to contain "${prop}"`).toContain(prop);
57
+ return this;
58
+ });
59
+ TestResponse.macro("assertInertiaScrollProp", async function(prop, expected) {
60
+ const actual = (await this.json()).scrollProps?.[prop];
61
+ expect(actual, `Expected Inertia scrollProps to contain "${prop}"`).toBeDefined();
62
+ if (expected) expect(actual, `Expected Inertia scroll prop "${prop}" to match ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toMatchObject(expected);
50
63
  return this;
51
64
  });
52
65
  TestResponse.macro("assertInertiaSharedProp", async function(prop) {
53
- expect((await this.json()).sharedProps, `Expected Inertia sharedProps to contain "${prop}"`).toContain(prop);
66
+ const page = await this.json();
67
+ expect(page.sharedProps, `Expected Inertia sharedProps to contain "${prop}"`).toContain(prop);
54
68
  return this;
55
69
  });
56
70
  TestResponse.macro("assertSuccessfulPrecognition", function() {