defuss 3.4.2 → 3.4.4

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.cjs CHANGED
@@ -1162,8 +1162,12 @@ const setupRouter = (config = {
1162
1162
  isReady: false,
1163
1163
  pendingResolvers: [],
1164
1164
  currentPath: "",
1165
- popAttached: false
1165
+ popAttached: false,
1166
+ lifecycleHooks: { beforeUnmount: [], unmount: [] }
1166
1167
  };
1168
+ if (!state.lifecycleHooks) {
1169
+ state.lifecycleHooks = { beforeUnmount: [], unmount: [] };
1170
+ }
1167
1171
  const routeRegistrations = state.routeRegistrations;
1168
1172
  if (typeof window !== "undefined" && !windowImpl) {
1169
1173
  windowImpl = globalThis.__defuss_window || window;
@@ -1286,6 +1290,35 @@ const setupRouter = (config = {
1286
1290
  resolve();
1287
1291
  }
1288
1292
  state.pendingResolvers = [];
1293
+ },
1294
+ createRouteContext(request) {
1295
+ return {
1296
+ request,
1297
+ onBeforeLeave(fn) {
1298
+ state.lifecycleHooks.beforeUnmount.push(fn);
1299
+ },
1300
+ onLeave(fn) {
1301
+ state.lifecycleHooks.unmount.push(fn);
1302
+ }
1303
+ };
1304
+ },
1305
+ async runBeforeUnmountHooks() {
1306
+ for (const fn of state.lifecycleHooks.beforeUnmount) {
1307
+ const result = await fn();
1308
+ if (result === false) return false;
1309
+ }
1310
+ return true;
1311
+ },
1312
+ runUnmountHooks() {
1313
+ for (const fn of state.lifecycleHooks.unmount) {
1314
+ fn();
1315
+ }
1316
+ },
1317
+ clearRouteLifecycle() {
1318
+ const oldUnmountHooks = [...state.lifecycleHooks.unmount];
1319
+ state.lifecycleHooks.beforeUnmount = [];
1320
+ state.lifecycleHooks.unmount = [];
1321
+ return { unmountHooks: oldUnmountHooks };
1289
1322
  }
1290
1323
  };
1291
1324
  const handlePopState = (event) => {
@@ -1315,7 +1348,8 @@ if (!globalThis[ROUTER_STATE_KEY]) {
1315
1348
  isReady: false,
1316
1349
  pendingResolvers: [],
1317
1350
  currentPath: "",
1318
- popAttached: false
1351
+ popAttached: false,
1352
+ lifecycleHooks: { beforeUnmount: [], unmount: [] }
1319
1353
  };
1320
1354
  }
1321
1355
  const getRouterState = () => globalThis[ROUTER_STATE_KEY];
@@ -1336,7 +1370,13 @@ const Route = ({
1336
1370
  const req = router.match(path);
1337
1371
  if (!req.match) return null;
1338
1372
  if (Component) {
1339
- return /* @__PURE__ */ jsx(Component, {});
1373
+ const routeContext = router.createRouteContext(req);
1374
+ const isAsync = Component.constructor.name === "AsyncFunction";
1375
+ const routeChildren = Array.isArray(children) ? children[0] : children;
1376
+ if (isAsync && routeChildren) {
1377
+ return /* @__PURE__ */ jsx(Component, { route: routeContext, fallback: routeChildren });
1378
+ }
1379
+ return /* @__PURE__ */ jsx(Component, { route: routeContext });
1340
1380
  }
1341
1381
  return Array.isArray(children) ? children[0] : children || null;
1342
1382
  };
@@ -1381,11 +1421,27 @@ const RouterSlot = ({
1381
1421
  router.onRouteChange(async () => {
1382
1422
  const currentPath = router.getRequest().path;
1383
1423
  const isSamePath = currentPath === lastPath;
1424
+ if (!isSamePath) {
1425
+ const allowed = await router.runBeforeUnmountHooks();
1426
+ if (!allowed) {
1427
+ window.history.pushState({}, "", lastPath);
1428
+ router.resolve(lastPath);
1429
+ return;
1430
+ }
1431
+ const { unmountHooks } = router.clearRouteLifecycle();
1432
+ await dom.$(ref).update(
1433
+ typeof RouterOutlet === "function" ? RouterOutlet() : [],
1434
+ transitionConfig
1435
+ );
1436
+ for (const fn of unmountHooks) {
1437
+ fn();
1438
+ }
1439
+ } else {
1440
+ await dom.$(ref).update(
1441
+ typeof RouterOutlet === "function" ? RouterOutlet() : []
1442
+ );
1443
+ }
1384
1444
  lastPath = currentPath;
1385
- await dom.$(ref).update(
1386
- typeof RouterOutlet === "function" ? RouterOutlet() : [],
1387
- isSamePath ? void 0 : transitionConfig
1388
- );
1389
1445
  });
1390
1446
  }
1391
1447
  if (document.getElementById(slotId)) {
package/dist/index.d.ts CHANGED
@@ -137,6 +137,60 @@ declare const T: ({ key, values, tag, ref, ...attrs }: TransProps<string>) => VN
137
137
  type OnHandleRouteChangeFn = (newRoute: string, oldRoute: string) => void;
138
138
  type OnRouteChangeFn = (cb: OnHandleRouteChangeFn) => void;
139
139
  type RouterStrategy = "page-refresh" | "slot-refresh";
140
+ type BeforeUnmountHookFn = () => boolean | void | Promise<boolean | void>;
141
+ type UnmountHookFn = () => void;
142
+ /**
143
+ * Context object passed to components rendered via Route's `component` prop.
144
+ * Provides access to the current route request and lifecycle hooks.
145
+ *
146
+ * @example
147
+ * ```tsx
148
+ * const MyScreen = ({ route }: { route: RouteContext }) => {
149
+ * route.onBeforeLeave(() => {
150
+ * // Return false to block navigation (e.g. unsaved changes)
151
+ * return confirm("Leave page?");
152
+ * });
153
+ * route.onLeave(() => {
154
+ * console.log("Route was left");
155
+ * });
156
+ * return <div>Current path: {route.request.path}</div>;
157
+ * };
158
+ * ```
159
+ */
160
+ interface RouteContext {
161
+ /** The matched RouteRequest for the current route */
162
+ request: RouteRequest;
163
+ /**
164
+ * Register a hook that fires before leaving the current route.
165
+ * Returning `false` (or a Promise resolving to `false`) blocks navigation,
166
+ * allowing implementation of confirmation dialogs.
167
+ */
168
+ onBeforeLeave(fn: BeforeUnmountHookFn): void;
169
+ /**
170
+ * Register a hook that fires after the route has been left
171
+ * (navigation completed, new route is rendered).
172
+ */
173
+ onLeave(fn: UnmountHookFn): void;
174
+ }
175
+ /**
176
+ * Props mixin for screen components rendered by `<Route component={...} />`.
177
+ * Extend your component props with this to get typed access to route context.
178
+ *
179
+ * @example
180
+ * ```tsx
181
+ * import { Router, type Props, type RouteProps } from "defuss";
182
+ *
183
+ * export interface ProjectDetailsProps extends Props, RouteProps {}
184
+ *
185
+ * export function ProjectDetailsScreen({ route }: ProjectDetailsProps) {
186
+ * const { projectName } = route.request.params;
187
+ * return <h1>Project: {projectName}</h1>;
188
+ * }
189
+ * ```
190
+ */
191
+ interface RouteProps {
192
+ route: RouteContext;
193
+ }
140
194
  interface RouterConfig {
141
195
  strategy?: RouterStrategy;
142
196
  }
@@ -268,6 +322,10 @@ interface RouterState {
268
322
  pendingResolvers: Array<() => void>;
269
323
  currentPath: string;
270
324
  popAttached: boolean;
325
+ lifecycleHooks: {
326
+ beforeUnmount: Array<BeforeUnmountHookFn>;
327
+ unmount: Array<UnmountHookFn>;
328
+ };
271
329
  }
272
330
  declare global {
273
331
  var __defuss_router__: Router | undefined;
@@ -301,10 +359,16 @@ interface Router {
301
359
  * ```
302
360
  */
303
361
  ready(): Promise<void>;
362
+ /**
363
+ * Create a RouteContext object for a matched route request.
364
+ * The context provides lifecycle hooks (onBeforeLeave, onLeave)
365
+ * and is passed to components rendered via Route's `component` prop.
366
+ */
367
+ createRouteContext(request: RouteRequest): RouteContext;
304
368
  }
305
369
  declare const Router: Router;
306
370
 
307
- interface RouteProps extends Props {
371
+ interface RouteComponentProps extends Props {
308
372
  path: string;
309
373
  router?: Router;
310
374
  exact?: boolean;
@@ -315,19 +379,29 @@ interface RouteProps extends Props {
315
379
  * has been registered and matched. This ensures `Router.getRequest()`
316
380
  * returns the correct params inside the component.
317
381
  *
318
- * Use this when your component needs route params on initial page load
319
- * (server-rendered / hard-reload scenarios).
382
+ * The component receives a `route` prop (RouteContext) with:
383
+ * - `route.request` the matched RouteRequest with params, query, etc.
384
+ * - `route.onBeforeLeave(fn)` — register a hook before route leaves; return `false` to block
385
+ * - `route.onLeave(fn)` — register a hook after route has been left
386
+ *
387
+ * When the component is async and Route has children, the children are
388
+ * shown as a loading fallback until the async component resolves.
320
389
  *
321
390
  * @example
322
391
  * ```tsx
323
392
  * <Route path="/project/:projectName" component={ProjectDetailsScreen} />
393
+ *
394
+ * // With loading fallback for async components:
395
+ * <Route path="/dashboard" component={AsyncDashboard}>
396
+ * <Spinner />
397
+ * </Route>
324
398
  * ```
325
399
  */
326
400
  component?: FC<any>;
327
401
  }
328
- declare const Route: FC<RouteProps>;
402
+ declare const Route: FC<RouteComponentProps>;
329
403
 
330
- interface RedirectProps extends RouteProps {
404
+ interface RedirectProps extends RouteComponentProps {
331
405
  to: string;
332
406
  }
333
407
  declare const Redirect: FC<RedirectProps>;
@@ -412,4 +486,4 @@ declare const Suspense: ({ fallback, ref, children, class: _class, className, id
412
486
  };
413
487
 
414
488
  export { Async, AsyncDefussChild, DOMElement, FC, Globals, NodeType, PersistenceProviderImpl, PersistenceProviderOptions, PersistenceProviderType, Props, Redirect, Ref, RenderInput, Route, Router, RouterSlot, RouterSlotId, Suspense, T, Trans, TransitionConfig, VNode, VNodeAttributes, VNodeChild, addElementEvent, areDomNodesEqual, changeLanguage, checkElementVisibility, clearElementEvents, createI18n, createTrans, domNodeToVNode, getEventMap, getLanguage, getMimeType, getRouterState, htmlStringToVNodes, i18n, inDevMode, isHTML, isMarkup, isSVG, loadLanguage, matchRouteRegistrations, parseDOM, processAllFormElements, queueCallback, removeElementEvent, renderMarkup, replaceDomWithVdom, setupRouter, t, tokenizePath, updateDomWithVdom, waitForDOM, webstorage };
415
- export type { AsyncProps, AsyncState, AsyncStateRef, I18nStore, MatchRouteRegistrationsOpts, OnHandleRouteChangeFn, OnLanguageChangeListener, OnRouteChangeFn, RedirectProps, Replacements, Resolve, RouteHandler, RouteParams, RouteProps, RouteRegistration, RouteRequest, RouterConfig, RouterSlotProps, RouterStrategy, TokenizedPath, TransProps, TransRef, TranslationKeys, TranslationObject, Translations, ValidChild };
489
+ export type { AsyncProps, AsyncState, AsyncStateRef, BeforeUnmountHookFn, I18nStore, MatchRouteRegistrationsOpts, OnHandleRouteChangeFn, OnLanguageChangeListener, OnRouteChangeFn, RedirectProps, Replacements, Resolve, RouteComponentProps, RouteContext, RouteHandler, RouteParams, RouteProps, RouteRegistration, RouteRequest, RouterConfig, RouterSlotProps, RouterStrategy, TokenizedPath, TransProps, TransRef, TranslationKeys, TranslationObject, Translations, UnmountHookFn, ValidChild };
package/dist/index.mjs CHANGED
@@ -1161,8 +1161,12 @@ const setupRouter = (config = {
1161
1161
  isReady: false,
1162
1162
  pendingResolvers: [],
1163
1163
  currentPath: "",
1164
- popAttached: false
1164
+ popAttached: false,
1165
+ lifecycleHooks: { beforeUnmount: [], unmount: [] }
1165
1166
  };
1167
+ if (!state.lifecycleHooks) {
1168
+ state.lifecycleHooks = { beforeUnmount: [], unmount: [] };
1169
+ }
1166
1170
  const routeRegistrations = state.routeRegistrations;
1167
1171
  if (typeof window !== "undefined" && !windowImpl) {
1168
1172
  windowImpl = globalThis.__defuss_window || window;
@@ -1285,6 +1289,35 @@ const setupRouter = (config = {
1285
1289
  resolve();
1286
1290
  }
1287
1291
  state.pendingResolvers = [];
1292
+ },
1293
+ createRouteContext(request) {
1294
+ return {
1295
+ request,
1296
+ onBeforeLeave(fn) {
1297
+ state.lifecycleHooks.beforeUnmount.push(fn);
1298
+ },
1299
+ onLeave(fn) {
1300
+ state.lifecycleHooks.unmount.push(fn);
1301
+ }
1302
+ };
1303
+ },
1304
+ async runBeforeUnmountHooks() {
1305
+ for (const fn of state.lifecycleHooks.beforeUnmount) {
1306
+ const result = await fn();
1307
+ if (result === false) return false;
1308
+ }
1309
+ return true;
1310
+ },
1311
+ runUnmountHooks() {
1312
+ for (const fn of state.lifecycleHooks.unmount) {
1313
+ fn();
1314
+ }
1315
+ },
1316
+ clearRouteLifecycle() {
1317
+ const oldUnmountHooks = [...state.lifecycleHooks.unmount];
1318
+ state.lifecycleHooks.beforeUnmount = [];
1319
+ state.lifecycleHooks.unmount = [];
1320
+ return { unmountHooks: oldUnmountHooks };
1288
1321
  }
1289
1322
  };
1290
1323
  const handlePopState = (event) => {
@@ -1314,7 +1347,8 @@ if (!globalThis[ROUTER_STATE_KEY]) {
1314
1347
  isReady: false,
1315
1348
  pendingResolvers: [],
1316
1349
  currentPath: "",
1317
- popAttached: false
1350
+ popAttached: false,
1351
+ lifecycleHooks: { beforeUnmount: [], unmount: [] }
1318
1352
  };
1319
1353
  }
1320
1354
  const getRouterState = () => globalThis[ROUTER_STATE_KEY];
@@ -1335,7 +1369,13 @@ const Route = ({
1335
1369
  const req = router.match(path);
1336
1370
  if (!req.match) return null;
1337
1371
  if (Component) {
1338
- return /* @__PURE__ */ jsx(Component, {});
1372
+ const routeContext = router.createRouteContext(req);
1373
+ const isAsync = Component.constructor.name === "AsyncFunction";
1374
+ const routeChildren = Array.isArray(children) ? children[0] : children;
1375
+ if (isAsync && routeChildren) {
1376
+ return /* @__PURE__ */ jsx(Component, { route: routeContext, fallback: routeChildren });
1377
+ }
1378
+ return /* @__PURE__ */ jsx(Component, { route: routeContext });
1339
1379
  }
1340
1380
  return Array.isArray(children) ? children[0] : children || null;
1341
1381
  };
@@ -1380,11 +1420,27 @@ const RouterSlot = ({
1380
1420
  router.onRouteChange(async () => {
1381
1421
  const currentPath = router.getRequest().path;
1382
1422
  const isSamePath = currentPath === lastPath;
1423
+ if (!isSamePath) {
1424
+ const allowed = await router.runBeforeUnmountHooks();
1425
+ if (!allowed) {
1426
+ window.history.pushState({}, "", lastPath);
1427
+ router.resolve(lastPath);
1428
+ return;
1429
+ }
1430
+ const { unmountHooks } = router.clearRouteLifecycle();
1431
+ await $(ref).update(
1432
+ typeof RouterOutlet === "function" ? RouterOutlet() : [],
1433
+ transitionConfig
1434
+ );
1435
+ for (const fn of unmountHooks) {
1436
+ fn();
1437
+ }
1438
+ } else {
1439
+ await $(ref).update(
1440
+ typeof RouterOutlet === "function" ? RouterOutlet() : []
1441
+ );
1442
+ }
1383
1443
  lastPath = currentPath;
1384
- await $(ref).update(
1385
- typeof RouterOutlet === "function" ? RouterOutlet() : [],
1386
- isSamePath ? void 0 : transitionConfig
1387
- );
1388
1444
  });
1389
1445
  }
1390
1446
  if (document.getElementById(slotId)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "defuss",
3
- "version": "3.4.2",
3
+ "version": "3.4.4",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"