@stratal/inertia-modal 0.0.21 → 0.0.23

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.
package/dist/index.d.mts CHANGED
@@ -17,7 +17,7 @@ interface ModalData {
17
17
  baseURL: string;
18
18
  redirectURL: string;
19
19
  key: string;
20
- nonce: string;
20
+ nativeBack: boolean;
21
21
  }
22
22
  interface ModalRenderOptions {
23
23
  baseURL: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/modal.module.ts","../src/tokens.ts","../src/services/modal.service.ts","../src/augment/router-context.ts"],"mappings":";;;cAca,WAAA,YAAuB,YAAA;EAClC,YAAA,CAAA;AAAA;;;cCfW,YAAA;EAAA,SAEH,YAAA;AAAA;;;UCKO,SAAA;EACf,SAAA;EACA,KAAA,EAAO,MAAA;EACP,OAAA;EACA,WAAA;EACA,GAAA;EACA,KAAA;AAAA;AAAA,UAGe,kBAAA;EACf,OAAA;AAAA;;;;YCZU,aAAA;IHSC;;;;;;;;ACdb;;;;IEkBI,YAAA,CACE,SAAA,UACA,KAAA,EAAO,MAAA,mBACP,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,QAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/modal.module.ts","../src/tokens.ts","../src/services/modal.service.ts","../src/augment/router-context.ts"],"mappings":";;;cAWa,WAAA,YAAuB,YAAY;EAC9C,YAAY;AAAA;;;cCZD,YAAA;EAAA,SAEH,YAAA;AAAA;;;UCKO,SAAA;EACf,SAAA;EACA,KAAA,EAAO,MAAM;EACb,OAAA;EACA,WAAA;EACA,GAAA;EACA,UAAA;AAAA;AAAA,UAGe,kBAAA;EACf,OAAO;AAAA;;;;YCZG,aAAA;IHMC;;;;AACC;;;;ACZd;;;;IEkBI,YAAA,CACE,SAAA,UACA,KAAA,EAAO,MAAA,mBACP,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,QAAA;EAAA;AAAA"}
package/dist/index.mjs CHANGED
@@ -1,8 +1,7 @@
1
- import { I18nModule } from "stratal/i18n";
2
1
  import { Module } from "stratal/module";
3
2
  import { ROUTER_TOKENS, RouterContext } from "stratal/router";
4
3
  import { INERTIA_TOKENS } from "@stratal/inertia";
5
- import { Transient, inject } from "stratal/di";
4
+ import { Request as Request$1, inject } from "stratal/di";
6
5
  import { HttpException } from "stratal/errors";
7
6
  //#region src/tokens.ts
8
7
  const MODAL_TOKENS = { ModalService: Symbol.for("stratal:inertia-modal:service") };
@@ -14,9 +13,6 @@ function augmentRouterContextWithModal(resolveService) {
14
13
  });
15
14
  }
16
15
  //#endregion
17
- //#region src/i18n/index.ts
18
- const i18nMessages = { en: { modal: { en: { errors: { backgroundFetchFailed: "Failed to load background page for modal" } } }.en } };
19
- //#endregion
20
16
  //#region src/errors/modal-background-fetch.error.ts
21
17
  /**
22
18
  * Thrown when the internal sub-request to fetch the background page fails
@@ -27,23 +23,18 @@ const i18nMessages = { en: { modal: { en: { errors: { backgroundFetchFailed: "Fa
27
23
  */
28
24
  var ModalBackgroundFetchError = class extends HttpException {
29
25
  constructor() {
30
- super(502, "modal.errors.backgroundFetchFailed");
26
+ super(502, "Failed to load background page for modal");
31
27
  }
32
28
  };
33
29
  //#endregion
34
- //#region \0@oxc-project+runtime@0.129.0/helpers/decorateMetadata.js
35
- function __decorateMetadata(k, v) {
36
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
37
- }
38
- //#endregion
39
- //#region \0@oxc-project+runtime@0.129.0/helpers/decorateParam.js
30
+ //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateParam.js
40
31
  function __decorateParam(paramIndex, decorator) {
41
32
  return function(target, key) {
42
33
  decorator(target, key, paramIndex);
43
34
  };
44
35
  }
45
36
  //#endregion
46
- //#region \0@oxc-project+runtime@0.129.0/helpers/decorate.js
37
+ //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
47
38
  function __decorate(decorators, target, key, desc) {
48
39
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
49
40
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -67,37 +58,46 @@ let ModalService = class ModalService {
67
58
  const partialData = ctx.c.req.header("x-inertia-partial-data");
68
59
  const redirectURL = this.resolveRedirectURL(ctx, options.baseURL);
69
60
  const key = ctx.c.req.header("x-inertia-modal-key") ?? crypto.randomUUID();
70
- const nonce = crypto.randomUUID();
71
61
  const modalURL = new URL(ctx.c.req.url).pathname;
62
+ if (isInertia && partialComponent && partialData) {
63
+ if (partialData.split(",").map((s) => s.trim()).includes("modal")) {
64
+ const page = {
65
+ component: partialComponent,
66
+ props: {
67
+ modal: {
68
+ component,
69
+ props,
70
+ baseURL: options.baseURL,
71
+ redirectURL,
72
+ key,
73
+ nativeBack: true
74
+ },
75
+ errors: {}
76
+ },
77
+ url: modalURL,
78
+ version: null,
79
+ flash: {},
80
+ rememberedState: {},
81
+ rescuedProps: []
82
+ };
83
+ return new Response(JSON.stringify(page), {
84
+ status: 200,
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ "X-Inertia": "true",
88
+ "Vary": "X-Inertia"
89
+ }
90
+ });
91
+ }
92
+ }
72
93
  const modalData = {
73
94
  component,
74
95
  props,
75
96
  baseURL: options.baseURL,
76
97
  redirectURL,
77
98
  key,
78
- nonce
99
+ nativeBack: false
79
100
  };
80
- if (isInertia && partialComponent && partialData) {
81
- if (partialData.split(",").map((s) => s.trim()).includes("modal")) return new Response(JSON.stringify({
82
- component: partialComponent,
83
- props: {
84
- modal: modalData,
85
- errors: {}
86
- },
87
- url: modalURL,
88
- version: null,
89
- flash: {},
90
- rememberedState: {},
91
- rescuedProps: []
92
- }), {
93
- status: 200,
94
- headers: {
95
- "Content-Type": "application/json",
96
- "X-Inertia": "true",
97
- "Vary": "X-Inertia"
98
- }
99
- });
100
- }
101
101
  const bgResponse = await this.fetchBackground(ctx, redirectURL);
102
102
  const bgText = await bgResponse.text();
103
103
  if (!bgText || bgResponse.status >= 300) throw new ModalBackgroundFetchError();
@@ -139,6 +139,7 @@ let ModalService = class ModalService {
139
139
  const bgURL = new URL(url, currentURL.origin);
140
140
  const headers = {
141
141
  "x-inertia": "true",
142
+ "x-inertia-resolve-deferred": "true",
142
143
  "accept": "application/json",
143
144
  "cookie": ctx.c.req.header("cookie") ?? "",
144
145
  "host": ctx.c.req.header("host") ?? ""
@@ -163,15 +164,10 @@ let ModalService = class ModalService {
163
164
  }
164
165
  };
165
166
  ModalService = __decorate([
166
- Transient(),
167
+ Request$1(),
167
168
  __decorateParam(0, inject(ROUTER_TOKENS.HonoApp)),
168
169
  __decorateParam(1, inject(INERTIA_TOKENS.SsrRenderer)),
169
- __decorateParam(2, inject(INERTIA_TOKENS.TemplateService)),
170
- __decorateMetadata("design:paramtypes", [
171
- Object,
172
- Object,
173
- Object
174
- ])
170
+ __decorateParam(2, inject(INERTIA_TOKENS.TemplateService))
175
171
  ], ModalService);
176
172
  //#endregion
177
173
  //#region src/modal.module.ts
@@ -182,13 +178,10 @@ let ModalModule = class ModalModule {
182
178
  });
183
179
  }
184
180
  };
185
- ModalModule = __decorate([Module({
186
- imports: [I18nModule.registerMessages(i18nMessages)],
187
- providers: [{
188
- provide: MODAL_TOKENS.ModalService,
189
- useClass: ModalService
190
- }]
191
- })], ModalModule);
181
+ ModalModule = __decorate([Module({ providers: [{
182
+ provide: MODAL_TOKENS.ModalService,
183
+ useClass: ModalService
184
+ }] })], ModalModule);
192
185
  //#endregion
193
186
  export { MODAL_TOKENS, ModalModule };
194
187
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/tokens.ts","../src/augment/router-context.ts","../src/i18n/en.ts","../src/i18n/index.ts","../src/errors/modal-background-fetch.error.ts","../src/services/modal.service.ts","../src/modal.module.ts"],"sourcesContent":["export const MODAL_TOKENS = {\n ModalService: Symbol.for('stratal:inertia-modal:service'),\n} as const\n","import { RouterContext } from 'stratal/router'\nimport type { ModalRenderOptions, ModalService } from '../services/modal.service'\nimport { MODAL_TOKENS } from '../tokens'\n\ndeclare module 'stratal/router' {\n interface RouterContext {\n /**\n * Renders a modal page component over a background page.\n *\n * The background page at `options.baseURL` is always rendered as the main\n * Inertia page. The given `component` and `props` are embedded in the\n * background page's `modal` prop and rendered as an overlay by the\n * client-side `<Modal>` component.\n *\n * Handles direct URL visits by fetching the background page in-process.\n * Handles partial reloads (e.g., cascading selects) when `only: ['modal']`\n * is requested.\n */\n inertiaModal(\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response>\n }\n}\n\nexport function augmentRouterContextWithModal(\n resolveService: (ctx: RouterContext) => ModalService,\n): void {\n RouterContext.macro('inertiaModal', function (\n this: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ) {\n const service = resolveService(this)\n return service.render(this, component, props, options)\n })\n}\n\nexport { MODAL_TOKENS }\n","export const modalMessages = {\n en: {\n errors: {\n backgroundFetchFailed: 'Failed to load background page for modal',\n },\n },\n} as const\n\ndeclare module 'stratal/i18n' {\n interface AppMessageNamespaces {\n modal: typeof modalMessages['en']\n }\n}\n","import { modalMessages } from './en'\n\nexport const i18nMessages = { en: { modal: modalMessages.en } }\n","import { HttpException } from 'stratal/errors'\n\n/**\n * Thrown when the internal sub-request to fetch the background page fails\n * (e.g., non-2xx response, redirect, or empty body).\n *\n * HTTP Status: 502 Bad Gateway — the modal service acted as a proxy and the\n * upstream (background page) returned an unexpected response.\n */\nexport class ModalBackgroundFetchError extends HttpException {\n constructor() {\n super(502, 'modal.errors.backgroundFetchFailed')\n }\n}\n","import type { Page } from '@inertiajs/core'\nimport { INERTIA_TOKENS, type SsrRendererService, type TemplateService } from '@stratal/inertia'\nimport { Transient, inject } from 'stratal/di'\nimport type { RouterContext } from 'stratal/router'\nimport { ROUTER_TOKENS } from 'stratal/router'\nimport { ModalBackgroundFetchError } from '../errors/modal-background-fetch.error'\n\nexport interface ModalData {\n component: string\n props: Record<string, unknown>\n baseURL: string\n redirectURL: string\n key: string\n nonce: string\n}\n\nexport interface ModalRenderOptions {\n baseURL: string\n}\n\n// Page from @inertiajs/core doesn't have a 'modal' prop — we extend it here\ntype PageWithModal = Page\n\n// HonoApp extends OpenAPIHono which extends Hono — it has a standard fetch() method\ninterface FetchableApp {\n fetch(request: Request, env: unknown, ctx: unknown): Promise<Response>\n}\n\n@Transient()\nexport class ModalService {\n constructor(\n @inject(ROUTER_TOKENS.HonoApp) private readonly app: FetchableApp,\n @inject(INERTIA_TOKENS.SsrRenderer) private readonly ssr: SsrRendererService,\n @inject(INERTIA_TOKENS.TemplateService) private readonly template: TemplateService,\n ) { }\n\n async render(\n ctx: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response> {\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n const partialComponent = ctx.c.req.header('x-inertia-partial-component')\n const partialData = ctx.c.req.header('x-inertia-partial-data')\n\n const redirectURL = this.resolveRedirectURL(ctx, options.baseURL)\n const key = ctx.c.req.header('x-inertia-modal-key') ?? crypto.randomUUID()\n const nonce = crypto.randomUUID()\n const modalURL = new URL(ctx.c.req.url).pathname\n\n const modalData: ModalData = {\n component,\n props,\n baseURL: options.baseURL,\n redirectURL,\n key,\n nonce,\n }\n\n // Partial reload requesting 'modal' — skip background sub-request,\n // return just the modal prop with fresh data\n if (isInertia && partialComponent && partialData) {\n const requestedProps = partialData.split(',').map((s) => s.trim())\n if (requestedProps.includes('modal')) {\n const page: PageWithModal = {\n component: partialComponent,\n props: { modal: modalData, errors: {} },\n url: modalURL,\n version: null,\n flash: {},\n rememberedState: {},\n rescuedProps: [],\n }\n return new Response(JSON.stringify(page), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n }\n\n // Fetch background page as an Inertia JSON request to get its component and\n // props without triggering SSR. We will run SSR ourselves below with the\n // combined page object so that page.url equals the modal URL in both the\n // SSR output and on the client — preventing React hydration mismatches.\n const bgResponse = await this.fetchBackground(ctx, redirectURL)\n const bgText = await bgResponse.text()\n if (!bgText || bgResponse.status >= 300) {\n throw new ModalBackgroundFetchError()\n }\n const bgPage = JSON.parse(bgText) as PageWithModal\n\n // Build the combined page: background props + modal data, URL = modal URL.\n // Setting url to the modal URL ensures Inertia's InitialVisit.handleDefault\n // calls history.replaceState with the modal URL (matching window.location),\n // so the address bar stays at the modal URL on direct visits.\n const combinedPage: PageWithModal = {\n ...bgPage,\n props: { ...bgPage.props, modal: modalData },\n url: modalURL,\n }\n\n if (isInertia) {\n // Inertia AJAX navigation: return JSON\n return new Response(JSON.stringify(combinedPage), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n\n // Full-page (direct visit): run SSR with the combined page so that\n // page.url = modalURL in both the server-rendered HTML and the client\n // hydration pass. The Modal component renders null during SSR (effects\n // don't run server-side), so there is no hydration mismatch.\n const ssrResult = await this.ssr.render(combinedPage)\n const html = this.template.render(combinedPage, ssrResult.head, ssrResult.body)\n return new Response(html, {\n status: 200,\n headers: { 'Content-Type': 'text/html; charset=utf-8' },\n })\n }\n\n private resolveRedirectURL(ctx: RouterContext, baseURL: string): string {\n const referer = ctx.c.req.header('referer')\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n\n if (isInertia && referer) {\n try {\n const refererURL = new URL(referer)\n const currentURL = new URL(ctx.c.req.url)\n if (refererURL.pathname !== currentURL.pathname) {\n // Preserve the query string so the background page (and the\n // post-close redirect) keeps the filter/pagination state the\n // user had on the list view — without this, opening a modal\n // resets the parent page to defaults.\n return refererURL.pathname + refererURL.search\n }\n }\n catch {\n // malformed referer — fall through to baseURL\n }\n }\n\n return baseURL\n }\n\n private async fetchBackground(ctx: RouterContext, url: string): Promise<Response> {\n const currentURL = new URL(ctx.c.req.url)\n const bgURL = new URL(url, currentURL.origin)\n\n const headers: Record<string, string> = {\n // Always request JSON — we run SSR ourselves with the combined page object\n 'x-inertia': 'true',\n // Deliberately omit x-inertia-version: the InertiaMiddleware version check\n // returns a 409 with no body when versions don't match, which would make\n // JSON.parse fail. Internal sub-requests don't need cache-bust checks.\n 'accept': 'application/json',\n // Forward auth/session cookies so the background request is authenticated\n 'cookie': ctx.c.req.header('cookie') ?? '',\n // Forward the host header so domain-pattern middleware can match the\n // request against the configured domain pattern. Without this, the host\n // resolves to the URL's origin (e.g., localhost:1234) which won't match\n // patterns like '{tenant}.admsn.test', causing a DomainMismatchError.\n 'host': ctx.c.req.header('host') ?? '',\n }\n\n // Forward proxy/forwarded-for headers when present so middleware that\n // reconstructs the canonical request URL (e.g. setting `appUrl` to\n // `https://...`) sees the same protocol/host the original request had.\n // Without this, downstream auth (better-auth's secure-cookie prefix is\n // derived from `baseURL`'s protocol) would look up the wrong cookie name\n // and the bg fetch would be unauthenticated — even though the cookie is\n // forwarded above.\n const passthrough = [\n 'x-forwarded-proto',\n 'x-forwarded-host',\n 'x-forwarded-for',\n 'x-forwarded-port',\n 'x-real-ip',\n 'accept-language',\n 'user-agent',\n ] as const\n for (const name of passthrough) {\n const value = ctx.c.req.header(name)\n if (value) headers[name] = value\n }\n\n const bgRequest = new Request(bgURL.toString(), { method: 'GET', headers })\n\n return this.app.fetch(bgRequest, ctx.c.env, ctx.c.executionCtx)\n }\n}\n","import { I18nModule } from 'stratal/i18n'\nimport type { OnInitialize } from 'stratal/module'\nimport { Module } from 'stratal/module'\nimport { augmentRouterContextWithModal } from './augment/router-context'\nimport { i18nMessages } from './i18n/index'\nimport { ModalService } from './services/modal.service'\nimport { MODAL_TOKENS } from './tokens'\n\n@Module({\n imports: [I18nModule.registerMessages(i18nMessages)],\n providers: [\n { provide: MODAL_TOKENS.ModalService, useClass: ModalService },\n ],\n})\nexport class ModalModule implements OnInitialize {\n onInitialize(): void {\n augmentRouterContextWithModal((ctx) => {\n return ctx.getContainer().resolve<ModalService>(MODAL_TOKENS.ModalService)\n })\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["RequestScoped"],"sources":["../src/tokens.ts","../src/augment/router-context.ts","../src/errors/modal-background-fetch.error.ts","../src/services/modal.service.ts","../src/modal.module.ts"],"sourcesContent":["export const MODAL_TOKENS = {\n ModalService: Symbol.for('stratal:inertia-modal:service'),\n} as const\n","import { RouterContext } from 'stratal/router'\nimport type { ModalRenderOptions, ModalService } from '../services/modal.service'\nimport { MODAL_TOKENS } from '../tokens'\n\ndeclare module 'stratal/router' {\n interface RouterContext {\n /**\n * Renders a modal page component over a background page.\n *\n * The background page at `options.baseURL` is always rendered as the main\n * Inertia page. The given `component` and `props` are embedded in the\n * background page's `modal` prop and rendered as an overlay by the\n * client-side `<Modal>` component.\n *\n * Handles direct URL visits by fetching the background page in-process.\n * Handles partial reloads (e.g., cascading selects) when `only: ['modal']`\n * is requested.\n */\n inertiaModal(\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response>\n }\n}\n\nexport function augmentRouterContextWithModal(\n resolveService: (ctx: RouterContext) => ModalService,\n): void {\n RouterContext.macro('inertiaModal', function (\n this: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ) {\n const service = resolveService(this)\n return service.render(this, component, props, options)\n })\n}\n\nexport { MODAL_TOKENS }\n","import { HttpException } from 'stratal/errors'\n\n/**\n * Thrown when the internal sub-request to fetch the background page fails\n * (e.g., non-2xx response, redirect, or empty body).\n *\n * HTTP Status: 502 Bad Gateway — the modal service acted as a proxy and the\n * upstream (background page) returned an unexpected response.\n */\nexport class ModalBackgroundFetchError extends HttpException {\n constructor() {\n super(502, 'Failed to load background page for modal')\n }\n}\n","import type { Page } from '@inertiajs/core'\nimport { INERTIA_TOKENS, type SsrRendererService, type TemplateService } from '@stratal/inertia'\nimport { Request as RequestScoped, inject } from 'stratal/di'\nimport type { RouterContext } from 'stratal/router'\nimport { ROUTER_TOKENS } from 'stratal/router'\nimport { ModalBackgroundFetchError } from '../errors/modal-background-fetch.error'\n\nexport interface ModalData {\n component: string\n props: Record<string, unknown>\n baseURL: string\n redirectURL: string\n key: string\n nativeBack: boolean\n}\n\nexport interface ModalRenderOptions {\n baseURL: string\n}\n\n// Page from @inertiajs/core doesn't have a 'modal' prop — we extend it here\ntype PageWithModal = Page\n\n// HonoApp extends OpenAPIHono which extends Hono — it has a standard fetch() method\ninterface FetchableApp {\n fetch(request: Request, env: unknown, ctx: unknown): Promise<Response>\n}\n\n@RequestScoped()\nexport class ModalService {\n constructor(\n @inject(ROUTER_TOKENS.HonoApp) private readonly app: FetchableApp,\n @inject(INERTIA_TOKENS.SsrRenderer) private readonly ssr: SsrRendererService,\n @inject(INERTIA_TOKENS.TemplateService) private readonly template: TemplateService,\n ) { }\n\n async render(\n ctx: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response> {\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n const partialComponent = ctx.c.req.header('x-inertia-partial-component')\n const partialData = ctx.c.req.header('x-inertia-partial-data')\n\n const redirectURL = this.resolveRedirectURL(ctx, options.baseURL)\n const key = ctx.c.req.header('x-inertia-modal-key') ?? crypto.randomUUID()\n const modalURL = new URL(ctx.c.req.url).pathname\n\n // Partial reload requesting 'modal' — skip background sub-request,\n // return just the modal prop with fresh data. The client already has\n // the background page loaded, so nativeBack: true tells useModal()\n // to use history.back() on close instead of a server round-trip.\n if (isInertia && partialComponent && partialData) {\n const requestedProps = partialData.split(',').map((s) => s.trim())\n if (requestedProps.includes('modal')) {\n const partialModalData: ModalData = {\n component,\n props,\n baseURL: options.baseURL,\n redirectURL,\n key,\n nativeBack: true,\n }\n const page: PageWithModal = {\n component: partialComponent,\n props: { modal: partialModalData, errors: {} },\n url: modalURL,\n version: null,\n flash: {},\n rememberedState: {},\n rescuedProps: [],\n }\n return new Response(JSON.stringify(page), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n }\n\n const modalData: ModalData = {\n component,\n props,\n baseURL: options.baseURL,\n redirectURL,\n key,\n nativeBack: false,\n }\n\n // Fetch background page as an Inertia JSON request to get its component and\n // props without triggering SSR. We will run SSR ourselves below with the\n // combined page object so that page.url equals the modal URL in both the\n // SSR output and on the client — preventing React hydration mismatches.\n const bgResponse = await this.fetchBackground(ctx, redirectURL)\n const bgText = await bgResponse.text()\n if (!bgText || bgResponse.status >= 300) {\n throw new ModalBackgroundFetchError()\n }\n const bgPage = JSON.parse(bgText) as PageWithModal\n\n // Build the combined page: background props + modal data, URL = modal URL.\n // Setting url to the modal URL ensures Inertia's InitialVisit.handleDefault\n // calls history.replaceState with the modal URL (matching window.location),\n // so the address bar stays at the modal URL on direct visits.\n const combinedPage: PageWithModal = {\n ...bgPage,\n props: { ...bgPage.props, modal: modalData },\n url: modalURL,\n }\n\n if (isInertia) {\n // Inertia AJAX navigation: return JSON\n return new Response(JSON.stringify(combinedPage), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n\n // Full-page (direct visit): run SSR with the combined page so that\n // page.url = modalURL in both the server-rendered HTML and the client\n // hydration pass. The Modal component renders null during SSR (effects\n // don't run server-side), so there is no hydration mismatch.\n const ssrResult = await this.ssr.render(combinedPage)\n const html = this.template.render(combinedPage, ssrResult.head, ssrResult.body)\n return new Response(html, {\n status: 200,\n headers: { 'Content-Type': 'text/html; charset=utf-8' },\n })\n }\n\n private resolveRedirectURL(ctx: RouterContext, baseURL: string): string {\n const referer = ctx.c.req.header('referer')\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n\n if (isInertia && referer) {\n try {\n const refererURL = new URL(referer)\n const currentURL = new URL(ctx.c.req.url)\n if (refererURL.pathname !== currentURL.pathname) {\n // Preserve the query string so the background page (and the\n // post-close redirect) keeps the filter/pagination state the\n // user had on the list view — without this, opening a modal\n // resets the parent page to defaults.\n return refererURL.pathname + refererURL.search\n }\n }\n catch {\n // malformed referer — fall through to baseURL\n }\n }\n\n return baseURL\n }\n\n private async fetchBackground(ctx: RouterContext, url: string): Promise<Response> {\n const currentURL = new URL(ctx.c.req.url)\n const bgURL = new URL(url, currentURL.origin)\n\n const headers: Record<string, string> = {\n // Always request JSON — we run SSR ourselves with the combined page object\n 'x-inertia': 'true',\n // Eagerly resolve deferred props so the background page renders with data\n 'x-inertia-resolve-deferred': 'true',\n // Deliberately omit x-inertia-version: the InertiaMiddleware version check\n // returns a 409 with no body when versions don't match, which would make\n // JSON.parse fail. Internal sub-requests don't need cache-bust checks.\n 'accept': 'application/json',\n // Forward auth/session cookies so the background request is authenticated\n 'cookie': ctx.c.req.header('cookie') ?? '',\n // Forward the host header so domain-pattern middleware can match the\n // request against the configured domain pattern. Without this, the host\n // resolves to the URL's origin (e.g., localhost:1234) which won't match\n // patterns like '{tenant}.admsn.test', causing a DomainMismatchError.\n 'host': ctx.c.req.header('host') ?? '',\n }\n\n // Forward proxy/forwarded-for headers when present so middleware that\n // reconstructs the canonical request URL (e.g. setting `appUrl` to\n // `https://...`) sees the same protocol/host the original request had.\n // Without this, downstream auth (better-auth's secure-cookie prefix is\n // derived from `baseURL`'s protocol) would look up the wrong cookie name\n // and the bg fetch would be unauthenticated — even though the cookie is\n // forwarded above.\n const passthrough = [\n 'x-forwarded-proto',\n 'x-forwarded-host',\n 'x-forwarded-for',\n 'x-forwarded-port',\n 'x-real-ip',\n 'accept-language',\n 'user-agent',\n ] as const\n for (const name of passthrough) {\n const value = ctx.c.req.header(name)\n if (value) headers[name] = value\n }\n\n const bgRequest = new Request(bgURL.toString(), { method: 'GET', headers })\n\n return this.app.fetch(bgRequest, ctx.c.env, ctx.c.executionCtx)\n }\n}\n","import type { OnInitialize } from 'stratal/module'\nimport { Module } from 'stratal/module'\nimport { augmentRouterContextWithModal } from './augment/router-context'\nimport { ModalService } from './services/modal.service'\nimport { MODAL_TOKENS } from './tokens'\n\n@Module({\n providers: [\n { provide: MODAL_TOKENS.ModalService, useClass: ModalService },\n ],\n})\nexport class ModalModule implements OnInitialize {\n onInitialize(): void {\n augmentRouterContextWithModal((ctx) => {\n return ctx.getContainer().resolve<ModalService>(MODAL_TOKENS.ModalService)\n })\n }\n}\n"],"mappings":"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"}
package/dist/react.d.mts CHANGED
@@ -1,5 +1,3 @@
1
- import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
-
3
1
  //#region src/react/modal.d.ts
4
2
  /**
5
3
  * Headless modal component. Place this anywhere in your layout.
@@ -24,7 +22,7 @@ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
24
22
  * }
25
23
  * ```
26
24
  */
27
- declare function Modal(): _$react_jsx_runtime0.JSX.Element | null;
25
+ declare function Modal(): import("react").JSX.Element | null;
28
26
  //#endregion
29
27
  //#region src/react/use-modal.d.ts
30
28
  interface UseModalReturn {
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/modal.tsx","../src/react/use-modal.ts","../src/react/resolver.ts"],"mappings":";;;;;;AAiCA;;;;;;;;;;;;;;;;;;ACnBA;;iBDmBgB,KAAA,CAAA,GAAK,oBAAA,CAAA,GAAA,CAAA,OAAA;;;UC5BX,cAAA;;EAER,IAAA;ED0Bc;ECxBd,QAAA;;EAEA,KAAA,EAAO,MAAA;AAAA;AAAA,iBAGO,QAAA,CAAA,GAAY,cAAA;;;cCVf,QAAA;WACF,IAAA"}
1
+ {"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/modal.tsx","../src/react/use-modal.ts","../src/react/resolver.ts"],"mappings":";;AAiCA;;;;AAAqB;;;;;;;;;;;;;ACrBN;AAGf;;;;iBDkBgB,KAAA,oBAAK,GAAA,CAAA,OAAA;;;UC3BX,cAAA;ED2BM;ECzBd,IAAA;;EAEA,QAAA;EDuBmB;ECrBnB,KAAA,EAAO,MAAM;AAAA;AAAA,iBAGC,QAAA,IAAY,cAAc;;;cCX7B,QAAA;WACF,IAAA"}
package/dist/react.mjs CHANGED
@@ -49,7 +49,7 @@ function Modal() {
49
49
  const component = mod?.default ?? mod;
50
50
  setComponent(() => component);
51
51
  }).catch(() => setComponent(null));
52
- }, [modal?.nonce]);
52
+ }, [modal?.component]);
53
53
  useEffect(() => {
54
54
  if (!modal?.key) return;
55
55
  return router.on("before", (event) => {
@@ -57,7 +57,7 @@ function Modal() {
57
57
  });
58
58
  }, [modal?.key]);
59
59
  if (!Component || !modal) return null;
60
- return /* @__PURE__ */ jsx(Component, { ...modal.props }, modal.key);
60
+ return /* @__PURE__ */ jsx(Component, { ...modal.props }, modal.component);
61
61
  }
62
62
  //#endregion
63
63
  //#region src/react/use-modal.ts
@@ -65,7 +65,8 @@ function useModal() {
65
65
  const modal = usePage().props.modal;
66
66
  const redirect = useCallback(() => {
67
67
  if (!modal) return;
68
- router.visit(modal.redirectURL ?? modal.baseURL, {
68
+ if (modal.nativeBack) window.history.back();
69
+ else router.visit(modal.redirectURL ?? modal.baseURL, {
69
70
  preserveScroll: true,
70
71
  preserveState: true
71
72
  });
@@ -1 +1 @@
1
- {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/resolver.ts","../src/react/modal.tsx","../src/react/use-modal.ts"],"sourcesContent":["// Module-level resolver store — persists across Inertia navigations.\n// Set once in app.tsx before createInertiaApp.\nlet resolveCallback: ((name: string) => unknown) | undefined\n\nexport const resolver = {\n set(cb: (name: string) => unknown): void {\n resolveCallback = cb\n },\n resolve(name: string): unknown {\n if (!resolveCallback) {\n throw new Error(\n '[@stratal/inertia-modal] Resolver not registered. '\n + 'Call resolver.set() before createInertiaApp().',\n )\n }\n return resolveCallback(name)\n },\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { router, usePage } from '@inertiajs/react'\nimport { type ComponentType, useEffect, useState } from 'react'\nimport type { ModalData } from '../services/modal.service'\nimport { resolver } from './resolver'\n\ninterface ModalPageProps extends PageProps {\n modal?: ModalData\n}\n\n/**\n * Headless modal component. Place this anywhere in your layout.\n *\n * When the current Inertia page has `props.modal` (set by `ctx.inertiaModal()`\n * on the server), this component dynamically loads the modal page component and\n * renders it as an overlay. The background page is what Inertia renders normally.\n *\n * @example\n * ```tsx\n * // dashboard-layout.tsx\n * import { Modal } from '@stratal/inertia-modal/react'\n *\n * export function DashboardLayout({ children }) {\n * return (\n * <>\n * <Sidebar />\n * <main>{children}</main>\n * <Modal />\n * </>\n * )\n * }\n * ```\n */\nexport function Modal() {\n const page = usePage<ModalPageProps>()\n const modal = page.props.modal\n\n const [Component, setComponent] = useState<ComponentType<Record<string, unknown>> | null>(null)\n\n useEffect(() => {\n if (!modal?.component) {\n setComponent(null)\n return\n }\n\n Promise.resolve(resolver.resolve(modal.component))\n .then((mod) => {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const component = (mod as any)?.default ?? mod\n setComponent(() => component as ComponentType<Record<string, unknown>>)\n })\n .catch(() => setComponent(null))\n // Re-load the component whenever nonce changes (new modal or refreshed props)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [modal?.nonce])\n\n // Inject x-inertia-modal-key into every Inertia request while the modal is\n // open. This ensures partial reloads (e.g. country → states cascade) reuse the\n // same key, preventing React from unmounting and remounting the modal component\n // (which would wipe all uncontrolled form state).\n useEffect(() => {\n if (!modal?.key) return\n\n return router.on('before', (event) => {\n event.detail.visit.headers['x-inertia-modal-key'] = modal.key\n })\n }, [modal?.key])\n\n if (!Component || !modal) {\n return null\n }\n\n return <Component key={modal.key} {...modal.props} />\n}\n","import { router, usePage } from '@inertiajs/react'\nimport { useCallback } from 'react'\nimport { type ModalData } from '../services/modal.service'\n\n\ninterface UseModalReturn {\n /** Whether a modal is currently active on this page. */\n show: boolean\n /** Navigate back to the page that opened the modal (or the base URL on direct visits). */\n redirect(): void\n /** The modal component's props, if a modal is active. */\n props: Record<string, unknown> | undefined\n}\n\nexport function useModal(): UseModalReturn {\n const page = usePage()\n const modal = page.props.modal as ModalData\n\n const redirect = useCallback(() => {\n if (!modal) return\n router.visit(modal.redirectURL ?? modal.baseURL, {\n preserveScroll: true,\n preserveState: true,\n })\n }, [modal])\n\n return {\n show: !!modal,\n redirect,\n props: modal?.props,\n }\n}\n"],"mappings":";;;;AAEA,IAAI;AAEJ,MAAa,WAAW;CACtB,IAAI,IAAqC;EACvC,kBAAkB;;CAEpB,QAAQ,MAAuB;EAC7B,IAAI,CAAC,iBACH,MAAM,IAAI,MACR,mGAED;EAEH,OAAO,gBAAgB,KAAK;;CAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;ACgBD,SAAgB,QAAQ;CAEtB,MAAM,QADO,SACK,CAAC,MAAM;CAEzB,MAAM,CAAC,WAAW,gBAAgB,SAAwD,KAAK;CAE/F,gBAAgB;EACd,IAAI,CAAC,OAAO,WAAW;GACrB,aAAa,KAAK;GAClB;;EAGF,QAAQ,QAAQ,SAAS,QAAQ,MAAM,UAAU,CAAC,CAC/C,MAAM,QAAQ;GAEb,MAAM,YAAa,KAAa,WAAW;GAC3C,mBAAmB,UAAoD;IACvE,CACD,YAAY,aAAa,KAAK,CAAC;IAGjC,CAAC,OAAO,MAAM,CAAC;CAMlB,gBAAgB;EACd,IAAI,CAAC,OAAO,KAAK;EAEjB,OAAO,OAAO,GAAG,WAAW,UAAU;GACpC,MAAM,OAAO,MAAM,QAAQ,yBAAyB,MAAM;IAC1D;IACD,CAAC,OAAO,IAAI,CAAC;CAEhB,IAAI,CAAC,aAAa,CAAC,OACjB,OAAO;CAGT,OAAO,oBAAC,WAAD,EAA2B,GAAI,MAAM,OAAS,EAA9B,MAAM,IAAwB;;;;AC1DvD,SAAgB,WAA2B;CAEzC,MAAM,QADO,SACK,CAAC,MAAM;CAEzB,MAAM,WAAW,kBAAkB;EACjC,IAAI,CAAC,OAAO;EACZ,OAAO,MAAM,MAAM,eAAe,MAAM,SAAS;GAC/C,gBAAgB;GAChB,eAAe;GAChB,CAAC;IACD,CAAC,MAAM,CAAC;CAEX,OAAO;EACL,MAAM,CAAC,CAAC;EACR;EACA,OAAO,OAAO;EACf"}
1
+ {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/resolver.ts","../src/react/modal.tsx","../src/react/use-modal.ts"],"sourcesContent":["// Module-level resolver store — persists across Inertia navigations.\n// Set once in app.tsx before createInertiaApp.\nlet resolveCallback: ((name: string) => unknown) | undefined\n\nexport const resolver = {\n set(cb: (name: string) => unknown): void {\n resolveCallback = cb\n },\n resolve(name: string): unknown {\n if (!resolveCallback) {\n throw new Error(\n '[@stratal/inertia-modal] Resolver not registered. '\n + 'Call resolver.set() before createInertiaApp().',\n )\n }\n return resolveCallback(name)\n },\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { router, usePage } from '@inertiajs/react'\nimport { type ComponentType, useEffect, useState } from 'react'\nimport type { ModalData } from '../services/modal.service'\nimport { resolver } from './resolver'\n\ninterface ModalPageProps extends PageProps {\n modal?: ModalData\n}\n\n/**\n * Headless modal component. Place this anywhere in your layout.\n *\n * When the current Inertia page has `props.modal` (set by `ctx.inertiaModal()`\n * on the server), this component dynamically loads the modal page component and\n * renders it as an overlay. The background page is what Inertia renders normally.\n *\n * @example\n * ```tsx\n * // dashboard-layout.tsx\n * import { Modal } from '@stratal/inertia-modal/react'\n *\n * export function DashboardLayout({ children }) {\n * return (\n * <>\n * <Sidebar />\n * <main>{children}</main>\n * <Modal />\n * </>\n * )\n * }\n * ```\n */\nexport function Modal() {\n const page = usePage<ModalPageProps>()\n const modal = page.props.modal\n\n const [Component, setComponent] = useState<ComponentType<Record<string, unknown>> | null>(null)\n\n useEffect(() => {\n if (!modal?.component) {\n setComponent(null)\n return\n }\n\n Promise.resolve(resolver.resolve(modal.component))\n .then((mod) => {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const component = (mod as any)?.default ?? mod\n setComponent(() => component as ComponentType<Record<string, unknown>>)\n })\n .catch(() => setComponent(null))\n }, [modal?.component])\n\n // Inject x-inertia-modal-key into every Inertia request while the modal is\n // open. This ensures partial reloads (e.g. country → states cascade) reuse the\n // same key, preventing React from unmounting and remounting the modal component\n // (which would wipe all uncontrolled form state).\n useEffect(() => {\n if (!modal?.key) return\n\n return router.on('before', (event) => {\n event.detail.visit.headers['x-inertia-modal-key'] = modal.key\n })\n }, [modal?.key])\n\n if (!Component || !modal) {\n return null\n }\n\n return <Component key={modal.component} {...modal.props} />\n}\n","import { router, usePage } from '@inertiajs/react';\nimport { useCallback } from 'react';\nimport { type ModalData } from '../services/modal.service';\n\ndeclare const window: { history: { back(): void } }\n\ninterface UseModalReturn {\n /** Whether a modal is currently active on this page. */\n show: boolean\n /** Navigate back to the page that opened the modal (or the base URL on direct visits). */\n redirect(): void\n /** The modal component's props, if a modal is active. */\n props: Record<string, unknown> | undefined\n}\n\nexport function useModal(): UseModalReturn {\n const page = usePage()\n const modal = page.props.modal as ModalData\n\n const redirect = useCallback(() => {\n if (!modal) return\n if (modal.nativeBack) {\n window.history.back()\n } else {\n router.visit(modal.redirectURL ?? modal.baseURL, {\n preserveScroll: true,\n preserveState: true,\n })\n }\n }, [modal])\n\n return {\n show: !!modal,\n redirect,\n props: modal?.props,\n }\n}\n"],"mappings":";;;;AAEA,IAAI;AAEJ,MAAa,WAAW;CACtB,IAAI,IAAqC;EACvC,kBAAkB;CACpB;CACA,QAAQ,MAAuB;EAC7B,IAAI,CAAC,iBACH,MAAM,IAAI,MACR,kGAEF;EAEF,OAAO,gBAAgB,IAAI;CAC7B;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;ACgBA,SAAgB,QAAQ;CAEtB,MAAM,QADO,QACI,EAAE,MAAM;CAEzB,MAAM,CAAC,WAAW,gBAAgB,SAAwD,IAAI;CAE9F,gBAAgB;EACd,IAAI,CAAC,OAAO,WAAW;GACrB,aAAa,IAAI;GACjB;EACF;EAEA,QAAQ,QAAQ,SAAS,QAAQ,MAAM,SAAS,CAAC,EAC9C,MAAM,QAAQ;GAEb,MAAM,YAAa,KAAa,WAAW;GAC3C,mBAAmB,SAAmD;EACxE,CAAC,EACA,YAAY,aAAa,IAAI,CAAC;CACnC,GAAG,CAAC,OAAO,SAAS,CAAC;CAMrB,gBAAgB;EACd,IAAI,CAAC,OAAO,KAAK;EAEjB,OAAO,OAAO,GAAG,WAAW,UAAU;GACpC,MAAM,OAAO,MAAM,QAAQ,yBAAyB,MAAM;EAC5D,CAAC;CACH,GAAG,CAAC,OAAO,GAAG,CAAC;CAEf,IAAI,CAAC,aAAa,CAAC,OACjB,OAAO;CAGT,OAAO,oBAAC,WAAD,EAAiC,GAAI,MAAM,MAAQ,GAAnC,MAAM,SAA6B;AAC5D;;;ACxDA,SAAgB,WAA2B;CAEzC,MAAM,QADO,QACI,EAAE,MAAM;CAEzB,MAAM,WAAW,kBAAkB;EACjC,IAAI,CAAC,OAAO;EACZ,IAAI,MAAM,YACR,OAAO,QAAQ,KAAK;OAEpB,OAAO,MAAM,MAAM,eAAe,MAAM,SAAS;GAC/C,gBAAgB;GAChB,eAAe;EACjB,CAAC;CAEL,GAAG,CAAC,KAAK,CAAC;CAEV,OAAO;EACL,MAAM,CAAC,CAAC;EACR;EACA,OAAO,OAAO;CAChB;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratal/inertia-modal",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Modal page primitive for Stratal Inertia — backend-driven modal dialogs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,11 +42,10 @@
42
42
  "peerDependencies": {
43
43
  "@inertiajs/core": ">=3",
44
44
  "@inertiajs/react": ">=3",
45
- "@stratal/inertia": ">=0.0.21",
45
+ "@stratal/inertia": ">=0.0.23",
46
46
  "hono": ">=4",
47
47
  "react": ">=19",
48
- "reflect-metadata": ">=0.2",
49
- "stratal": ">=0.0.21"
48
+ "stratal": ">=0.0.23"
50
49
  },
51
50
  "peerDependenciesMeta": {
52
51
  "@inertiajs/core": {
@@ -60,17 +59,16 @@
60
59
  }
61
60
  },
62
61
  "devDependencies": {
63
- "@cloudflare/workers-types": "4.20260510.1",
64
- "@inertiajs/core": "^3.1.1",
65
- "@inertiajs/react": "^3.1.1",
62
+ "@cloudflare/workers-types": "4.20260603.1",
63
+ "@inertiajs/core": "^3.3.0",
64
+ "@inertiajs/react": "^3.3.0",
66
65
  "@stratal/inertia": "workspace:*",
67
- "@types/node": "^25.6.2",
68
- "@types/react": "^19.2.14",
69
- "hono": "^4.12.18",
70
- "react": "^19.2.6",
71
- "reflect-metadata": "^0.2.2",
66
+ "@types/node": "^25.9.1",
67
+ "@types/react": "^19.2.16",
68
+ "hono": "^4.12.23",
69
+ "react": "^19.2.7",
72
70
  "stratal": "workspace:*",
73
- "tsdown": "^0.22.0",
71
+ "tsdown": "^0.22.1",
74
72
  "typescript": "^6.0.3"
75
73
  }
76
74
  }