@djangocfg/layouts 2.1.542 → 2.1.543

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.542",
3
+ "version": "2.1.543",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -90,11 +90,11 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/analytics": "^2.1.542",
94
- "@djangocfg/api": "^2.1.542",
95
- "@djangocfg/devtools": "^2.1.542",
96
- "@djangocfg/i18n": "^2.1.542",
97
- "@djangocfg/ui-core": "^2.1.542",
93
+ "@djangocfg/analytics": "^2.1.543",
94
+ "@djangocfg/api": "^2.1.543",
95
+ "@djangocfg/devtools": "^2.1.543",
96
+ "@djangocfg/i18n": "^2.1.543",
97
+ "@djangocfg/ui-core": "^2.1.543",
98
98
  "@hookform/resolvers": "^5.2.2",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -124,12 +124,12 @@
124
124
  "uuid": "^11.1.1"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.542",
128
- "@djangocfg/api": "^2.1.542",
129
- "@djangocfg/devtools": "^2.1.542",
130
- "@djangocfg/i18n": "^2.1.542",
131
- "@djangocfg/typescript-config": "^2.1.542",
132
- "@djangocfg/ui-core": "^2.1.542",
127
+ "@djangocfg/analytics": "^2.1.543",
128
+ "@djangocfg/api": "^2.1.543",
129
+ "@djangocfg/devtools": "^2.1.543",
130
+ "@djangocfg/i18n": "^2.1.543",
131
+ "@djangocfg/typescript-config": "^2.1.543",
132
+ "@djangocfg/ui-core": "^2.1.543",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -25,6 +25,7 @@ import { usePrivateLayoutContext } from '../context';
25
25
  import { useShellVisualState } from '../hooks';
26
26
  import type { SidebarGroupConfig } from '../types';
27
27
  import { SidebarNavItem } from './SidebarNavItem';
28
+ import { shallowEqualBy } from '../../../utils/shallowEqualBy';
28
29
 
29
30
  interface SidebarNavGroupProps {
30
31
  group: SidebarGroupConfig;
@@ -164,18 +165,17 @@ function groupShallowEqual(a: SidebarGroupConfig, b: SidebarGroupConfig): boolea
164
165
  a.icon === b.icon &&
165
166
  a.hideItemIcons === b.hideItemIcons &&
166
167
  a.dynamic === b.dynamic &&
167
- a.items.length === b.items.length &&
168
- a.items.every((ai, i) => {
169
- const bi = b.items[i];
170
- return (
168
+ shallowEqualBy(
169
+ a.items,
170
+ b.items,
171
+ (ai, bi) =>
171
172
  ai.href === bi.href &&
172
173
  ai.label === bi.label &&
173
174
  ai.icon === bi.icon &&
174
175
  ai.badge === bi.badge &&
175
176
  ai.badgeVariant === bi.badgeVariant &&
176
- ai.tooltip === bi.tooltip
177
- );
178
- })
177
+ ai.tooltip === bi.tooltip,
178
+ )
179
179
  );
180
180
  }
181
181
 
@@ -58,17 +58,18 @@ export function useResponsiveOverflow({
58
58
  const available = container.clientWidth;
59
59
  if (available <= 0) return;
60
60
 
61
- const widths: number[] = [];
62
- for (let i = 0; i < total; i++) {
61
+ // Built with `from` rather than an index loop so `widths` and `total` cannot
62
+ // disagree on length — the two reads below then need no bounds check.
63
+ const widths = Array.from({ length: total }, (_, i) => {
63
64
  const el = itemElsRef.current[i];
64
- widths.push(el ? Math.ceil(el.getBoundingClientRect().width) : 0);
65
- }
65
+ return el ? Math.ceil(el.getBoundingClientRect().width) : 0;
66
+ });
66
67
 
67
68
  // Try to fit all first.
68
- let usedAll = 0;
69
- for (let i = 0; i < total; i++) {
70
- usedAll += widths[i] + (i > 0 ? gap : 0);
71
- }
69
+ const usedAll = widths.reduce(
70
+ (sum, width, i) => sum + width + (i > 0 ? gap : 0),
71
+ 0,
72
+ );
72
73
  if (usedAll <= available) {
73
74
  setVisibleCount(total);
74
75
  setMeasured(true);
@@ -79,8 +80,10 @@ export function useResponsiveOverflow({
79
80
  const budget = available - moreWidth - gap;
80
81
  let used = 0;
81
82
  let fit = 0;
82
- for (let i = 0; i < total; i++) {
83
- const next = used + widths[i] + (i > 0 ? gap : 0);
83
+ // Iterating values, not indices: this loop breaks early, so `reduce` does
84
+ // not fit, and `entries()` still keeps each width tied to its position.
85
+ for (const [i, width] of widths.entries()) {
86
+ const next = used + width + (i > 0 ? gap : 0);
84
87
  if (next > budget) break;
85
88
  used = next;
86
89
  fit = i + 1;
@@ -10,6 +10,7 @@ import { UserMenu } from '../components/UserMenu';
10
10
  import type { UserMenuConfig } from '../../types';
11
11
 
12
12
  import { NavActionItem, type NavAction } from './NavActionItem';
13
+ import { shallowEqualBy } from '../../../utils/shallowEqualBy';
13
14
 
14
15
  interface NavActionsProps {
15
16
  userMenu?: UserMenuConfig;
@@ -133,18 +134,15 @@ function NavActionsRaw({
133
134
  * skip on reference churn from parent re-renders.
134
135
  */
135
136
  function actionsEqual(a?: NavAction[], b?: NavAction[]): boolean {
136
- if (a === b) return true;
137
- if (!a || !b) return false;
138
- if (a.length !== b.length) return false;
139
- return a.every((ai, i) => {
140
- const bi = b[i];
141
- return (
137
+ return shallowEqualBy(
138
+ a,
139
+ b,
140
+ (ai, bi) =>
142
141
  ai.label === bi.label &&
143
142
  ai.href === bi.href &&
144
143
  ai.variant === bi.variant &&
145
- ai.external === bi.external
146
- );
147
- });
144
+ ai.external === bi.external,
145
+ );
148
146
  }
149
147
 
150
148
  /**
@@ -8,6 +8,7 @@ import { Button, Link } from '@djangocfg/ui-core/components';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
9
9
 
10
10
  import type { NavigationItem } from '../../types';
11
+ import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
11
12
  import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
12
13
  import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
13
14
  import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
@@ -317,28 +318,6 @@ function NavDesktopItemsRaw({
317
318
  );
318
319
  }
319
320
 
320
- /**
321
- * Shallow equality for NavigationItem arrays. Compares href, label, external
322
- * and one-level-deep sub-items so that nav label/badge changes re-render,
323
- * but object-reference churn from parent is ignored.
324
- */
325
- function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
326
- if (a === b) return true;
327
- if (a.length !== b.length) return false;
328
- return a.every((ai, i) => {
329
- const bi = b[i];
330
- if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
331
- if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
332
- if (ai.items) {
333
- return ai.items.every((sub, j) => {
334
- const bsub = bi.items![j];
335
- return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
336
- });
337
- }
338
- return true;
339
- });
340
- }
341
-
342
321
  /**
343
322
  * Memoised desktop navigation row. Re-renders only when:
344
323
  * - the items array content changes (via navItemsShallowEqual),
@@ -29,6 +29,7 @@ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
29
29
  import { cn } from '@djangocfg/ui-core/lib';
30
30
 
31
31
  import { usePathnameWithoutLocale } from '../../../hooks';
32
+ import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
32
33
  import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
33
34
  import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
34
35
  import { usePublicLayoutOptional } from '../context';
@@ -420,28 +421,6 @@ function NavbarShellRaw(props: NavbarShellProps) {
420
421
  );
421
422
  }
422
423
 
423
- /**
424
- * Shallow equality for NavigationItem arrays. Compares href, label, external
425
- * and one-level-deep sub-items so that nav label/badge changes re-render,
426
- * but object-reference churn from parent is ignored.
427
- */
428
- function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
429
- if (a === b) return true;
430
- if (a.length !== b.length) return false;
431
- return a.every((ai, i) => {
432
- const bi = b[i];
433
- if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
434
- if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
435
- if (ai.items) {
436
- return ai.items.every((sub, j) => {
437
- const bsub = bi.items![j];
438
- return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
439
- });
440
- }
441
- return true;
442
- });
443
- }
444
-
445
424
  /**
446
425
  * Memoised navbar orchestrator. Re-renders only when any config prop changes
447
426
  * (variant, position, nav items, actions, slots, etc.). The heavy inner JSX
@@ -0,0 +1,34 @@
1
+ import type { NavigationItem } from '../layouts/types/ui.types';
2
+ import { shallowEqualBy } from './shallowEqualBy';
3
+
4
+ /**
5
+ * Shallow equality for NavigationItem arrays. Compares href, label, external
6
+ * and one-level-deep sub-items so that nav label/badge changes re-render,
7
+ * but object-reference churn from parent is ignored.
8
+ *
9
+ * Lived as two byte-identical copies in NavbarShell and NavDesktopItems.
10
+ * The sub-item pass used `bi.items![j]`, an assertion resting on the length
11
+ * comparison two lines above it; `shallowEqualBy` makes that structural
12
+ * instead, so the non-null assertion is gone rather than relocated.
13
+ */
14
+ export function navItemsShallowEqual(
15
+ a: NavigationItem[],
16
+ b: NavigationItem[],
17
+ ): boolean {
18
+ return shallowEqualBy(
19
+ a,
20
+ b,
21
+ (ai, bi) =>
22
+ ai.href === bi.href &&
23
+ ai.label === bi.label &&
24
+ ai.external === bi.external &&
25
+ shallowEqualBy(
26
+ ai.items,
27
+ bi.items,
28
+ (sub, bsub) =>
29
+ sub.href === bsub.href &&
30
+ sub.label === bsub.label &&
31
+ sub.external === bsub.external,
32
+ ),
33
+ );
34
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pairwise array comparison for `React.memo` props.
3
+ *
4
+ * Four hand-rolled copies of this existed across the nav layouts — two of them
5
+ * (`navItemsShallowEqual` in NavbarShell and NavDesktopItems) byte-identical.
6
+ * Each wrote `const bi = b[i]` inside `a.every`, which is safe only because the
7
+ * length was compared on the line above; under `strict` that produced 26 of the
8
+ * package's 28 errors, all asking the same question about the same missing
9
+ * connection between the guard and the index.
10
+ *
11
+ * `entries()` carries the pair, so an element and its counterpart are produced
12
+ * together and neither can be `undefined`. The guard is not restated — it is
13
+ * expressed once, in the shape of the loop.
14
+ */
15
+ export function shallowEqualBy<T>(
16
+ a: readonly T[] | undefined,
17
+ b: readonly T[] | undefined,
18
+ equal: (x: T, y: T) => boolean,
19
+ ): boolean {
20
+ if (a === b) return true;
21
+ if (!a || !b || a.length !== b.length) return false;
22
+ for (const [index, left] of a.entries()) {
23
+ const right = b[index];
24
+ // `index` comes from `a.entries()` and the lengths matched, so `right`
25
+ // exists. The check is what proves it to the compiler; it is not reachable.
26
+ if (right === undefined || !equal(left, right)) return false;
27
+ }
28
+ return true;
29
+ }