Skip to content

Commit 5941ff3

Browse files
authored
Improved server rendering support for defaultSize prop (#696)
Explore the idea discussed in #692 about using `defaultSize` during the initial render to improve server-side DevX. > [!NOTE] > This change is not enough to prevent layout shift _entirely_ in all scenarios, but it should dramatically reduce it. For example: ```tsx <Group> <Panel>left</Panel> <Panel defaultSize="25%">right</Panel> </Group> ``` Because of the way this library works, the eventual panel styles (after hydration) will be: ```css [left-panel] { flex: 75 1 0; } [right-panel] { flex: 25 1 0; } ``` It's possible to server-render the right panel with a style that is compatible with its goal style, but it's less obvious what style the left panel should server-render with. Since neither the left panel nor the parent group know yet about the right panel's default style, the best the left panel can server-render with is the style this library currently uses, meaning: ```css [left-panel] { flex-grow: 1; } [right-panel] { flex-basis: 25%; } ``` The resulting layout will be pretty close to the eventual layout, but it won't be an exact match if the parent group uses the [gap](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/gap) style. The difference can be seen with this reduced HTML example: ```html <div style="width: 500px"> <div>Server vs client</div> <div style="display: flex; gap: 1rem"> <div style="background-color: #afa; flex-grow: 1">left</div> <div style="background-color: faa; flex-basis: 25%">right</div> </div> <div style="display: flex; gap: 1rem"> <div style="background-color: #afa; flex: 75 1 0">left</div> <div style="background-color: #faa; flex: 25 1 0">right</div> </div> </div> ``` <img width="516" height="71" alt="Screenshot 2026-03-22 at 10 03 23 AM" src="https://github.com/user-attachments/assets/3b0cf7f7-cbdb-4877-81f1-e30597c25925" /> The larger the (flex) gap, the more noticeable the shift. <img width="520" height="239" alt="Screenshot 2026-03-22 at 10 04 12 AM" src="https://github.com/user-attachments/assets/f0046e96-3843-4359-bef1-7df3d6b77ddc" /> > [!NOTE] > The layout shift shown above only applies to percentage-based sizes. Pixels, ems/rems, and viewport based units do not exhibit this behavior. This project's server-rendering test harness illustrates the amount of layout shift that occurs with a moderate flex gap style. Though there's still some layout shift, it's a big improvement over the previous behavior. <table><thead><tr><th>Before</th><th>After</th></tr></thead><tbody><tr><td> https://github.com/user-attachments/assets/a4b1b07d-cd73-47ec-8d65-5db243d3a733 </td><td> https://github.com/user-attachments/assets/add46526-e2c9-4a23-b98d-7566aab0a84e </td></tr></tbody></table> --- Resolves #695
1 parent 461b0d1 commit 5941ff3

11 files changed

Lines changed: 111 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,8 @@ Falls back to <code>useId</code> when not provided.</p>
235235
<tr>
236236
<td>defaultSize</td>
237237
<td><p>Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.</p>
238-
<p>⚠️ This prop is useful for client side rendering but may cause problems when used with server rendering.
239-
It is recommended to use the <code>defaultLayout</code> prop of the parent <code>Group</code> instead.</p>
238+
<p>⚠️ Percentage based sizes may cause slight layout shift when server-rendering.
239+
For more information see the documentation.</p>
240240
</td>
241241
</tr>
242242
<tr>

integrations/next/app/page.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default async function Home() {
1010
return (
1111
<div className="p-2 flex flex-col gap-2">
1212
<LayoutShiftDetecter />
13+
<div className="text-lg">Group: Default layout</div>
1314
<Group
1415
className="h-25 gap-2"
1516
defaultLayout={defaultLayoutOne}
@@ -59,10 +60,32 @@ export default async function Home() {
5960
id: bottom
6061
</Panel>
6162
</Group>
63+
<div className="text-lg">Panel: Default sizes</div>
64+
<DefaultSize defaultSize="25%" />
65+
<DefaultSize defaultSize="100px" />
66+
<DefaultSize defaultSize="25vw" />
67+
<DefaultSize defaultSize="15rem" />
6268
</div>
6369
);
6470
}
6571

72+
function DefaultSize({ defaultSize }: { defaultSize: string }) {
73+
return (
74+
<Group className="h-25 gap-2">
75+
<Panel className="bg-slate-800 rounded rounded-md p-2" id="left">
76+
left
77+
</Panel>
78+
<Panel
79+
className="bg-slate-800 rounded rounded-md p-2"
80+
defaultSize={defaultSize}
81+
id="right"
82+
>
83+
right: {defaultSize}
84+
</Panel>
85+
</Group>
86+
);
87+
}
88+
6689
async function getDefaultLayout(groupId: string) {
6790
const api = await cookies();
6891
const defaultLayoutString = api.get(groupId)?.value;

integrations/vike/pages/index/+Page.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function Page() {
2323
return (
2424
<div className="p-2 flex flex-col gap-2">
2525
<LayoutShiftDetecter />
26-
26+
<div className="text-lg">Group: Default layout</div>
2727
<Group className="h-25 gap-2" {...groupOneProps}>
2828
<Panel
2929
className="bg-slate-800 rounded rounded-md p-2"
@@ -68,6 +68,28 @@ export default function Page() {
6868
id: bottom
6969
</Panel>
7070
</Group>
71+
<div className="text-lg">Panel: Default sizes</div>
72+
<DefaultSize defaultSize="25%" />
73+
<DefaultSize defaultSize="100px" />
74+
<DefaultSize defaultSize="25vw" />
75+
<DefaultSize defaultSize="15rem" />
7176
</div>
7277
);
7378
}
79+
80+
function DefaultSize({ defaultSize }: { defaultSize: string }) {
81+
return (
82+
<Group className="h-25 gap-2">
83+
<Panel className="bg-slate-800 rounded rounded-md p-2" id="left">
84+
left
85+
</Panel>
86+
<Panel
87+
className="bg-slate-800 rounded rounded-md p-2"
88+
defaultSize={defaultSize}
89+
id="right"
90+
>
91+
right: {defaultSize}
92+
</Panel>
93+
</Group>
94+
);
95+
}

integrations/vite/src/routes/Home.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function HomeRoute() {
1818
return (
1919
<div className="p-2 flex flex-col gap-2">
2020
<LayoutShiftDetecter />
21-
21+
<div className="text-lg">Group: Default layout</div>
2222
<Group className="h-25 gap-2" {...groupOneProps}>
2323
<Panel
2424
className="bg-slate-800 rounded rounded-md p-2"
@@ -63,6 +63,28 @@ export function HomeRoute() {
6363
id: bottom
6464
</Panel>
6565
</Group>
66+
<div className="text-lg">Panel: Default sizes</div>
67+
<DefaultSize defaultSize="25%" />
68+
<DefaultSize defaultSize="100px" />
69+
<DefaultSize defaultSize="25vw" />
70+
<DefaultSize defaultSize="15rem" />
6671
</div>
6772
);
6873
}
74+
75+
function DefaultSize({ defaultSize }: { defaultSize: string }) {
76+
return (
77+
<Group className="h-25 gap-2">
78+
<Panel className="bg-slate-800 rounded rounded-md p-2" id="left">
79+
left
80+
</Panel>
81+
<Panel
82+
className="bg-slate-800 rounded rounded-md p-2"
83+
defaultSize={defaultSize}
84+
id="right"
85+
>
86+
right: {defaultSize}
87+
</Panel>
88+
</Group>
89+
);
90+
}

lib/components/group/Group.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,11 @@ export function Group({
137137
}
138138

139139
// This is unexpected except for the initial mount (before the group has registered with the global store)
140-
return {
141-
flexGrow: defaultLayout?.[panelId] ?? 1
142-
} satisfies CSSProperties;
140+
if (defaultLayout?.[panelId]) {
141+
return {
142+
flexGrow: defaultLayout?.[panelId]
143+
} satisfies CSSProperties;
144+
}
143145
}
144146
);
145147

lib/components/group/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ export type RegisteredGroup = Readonly<{
4949

5050
export type GroupContextType = {
5151
disableCursor: boolean;
52-
getPanelStyles: (groupId: string, panelId: string) => CSSProperties;
52+
getPanelStyles: (
53+
groupId: string,
54+
panelId: string
55+
) => CSSProperties | undefined;
5356
id: string;
5457
orientation: Orientation;
5558
registerPanel: (panel: RegisteredPanel) => () => void;

lib/components/panel/Panel.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,34 @@ export function Panel({
137137

138138
usePanelImperativeHandle(id, panelRef);
139139

140+
// useSyncExternalStore does not support a custom equality check
141+
// stringify avoids re-rendering when the style value hasn't changed
142+
const read = () => {
143+
const maybePanelStyles = getPanelStyles(groupId, id);
144+
if (maybePanelStyles) {
145+
return JSON.stringify(maybePanelStyles);
146+
}
147+
};
148+
140149
const panelStylesString = useSyncExternalStore(
141150
(subscribe) => subscribeToMountedGroup(groupId, subscribe),
142-
143-
// useSyncExternalStore does not support a custom equality check
144-
// stringify avoids re-rendering when the style value hasn't changed
145-
() => JSON.stringify(getPanelStyles(groupId, id)),
146-
() => JSON.stringify(getPanelStyles(groupId, id))
151+
read,
152+
read
147153
);
148154

155+
let panelStyles: CSSProperties;
156+
if (panelStylesString) {
157+
panelStyles = JSON.parse(panelStylesString);
158+
} else if (defaultSize) {
159+
panelStyles = {
160+
flexGrow: undefined,
161+
flexShrink: undefined,
162+
flexBasis: defaultSize
163+
};
164+
} else {
165+
panelStyles = { flexGrow: 1 };
166+
}
167+
149168
return (
150169
<div
151170
{...rest}
@@ -162,7 +181,7 @@ export function Panel({
162181
flexShrink: 1,
163182
overflow: "visible",
164183

165-
...JSON.parse(panelStylesString)
184+
...panelStyles
166185
}}
167186
>
168187
<div

lib/components/panel/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ export type PanelProps = BasePanelAttributes & {
119119
/**
120120
* Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.
121121
*
122-
* ⚠️ This prop is useful for client side rendering but may cause problems when used with server rendering.
123-
* It is recommended to use the `defaultLayout` prop of the parent `Group` instead.
122+
* ⚠️ Percentage based sizes may cause slight layout shift when server-rendering.
123+
* For more information see the documentation.
124124
*/
125125
defaultSize?: number | string | undefined;
126126

public/generated/docs/Panel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
"content": "<p>Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.</p>\n"
105105
},
106106
{
107-
"content": "<p>This prop is useful for client side rendering but may cause problems when used with server rendering.\nIt is recommended to use the <code>defaultLayout</code> prop of the parent <code>Group</code> instead.</p>\n",
107+
"content": "<p>Percentage based sizes may cause slight layout shift when server-rendering.\nFor more information see the documentation.</p>\n",
108108
"intent": "warning"
109109
}
110110
],

public/generated/search-index.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,8 @@
322322
"n": 1
323323
},
324324
"2": {
325-
"v": "A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior.\nPanel size props can be in the following formats:\n\nPercentage of the parent Group (0..100)\nPixels\nRelative font units (em, rem)\nViewport relative units (vh, vw)\n\nNumeric values are assumed to be pixels.\nStrings without explicit units are assumed to be percentages (0%..100%).\nPercentages may also be specified as strings ending with \"%\" (e.g. \"33%\")\nPixels may also be specified as strings ending with the unit \"px\".\nOther units should be specified as strings ending with their CSS property units (e.g. 1rem, 50vh)\nPanel elements always include the following attributes:\n<div data-panel data-testid=\"panel-id-prop\" id=\"panel-id-prop\">Test id can be used to narrow selection when unit testing.\nPanel elements must be direct DOM children of their parent Group elements.\n Optional propsclassName?: stringCSS class name.\nClass is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\ncollapsedSize?: string | number = \"0%\"Panel size when collapsed; defaults to 0%.\ncollapsible?: boolean = falseThis panel can be collapsed.\nA collapsible panel will collapse when it's size is less than of the specified minSize\ndefaultSize?: string | numberDefault size of Panel within its parent group; default is auto-assigned based on the total number of Panels.\nThis prop is useful for client side rendering but may cause problems when used with server rendering.\nIt is recommended to use the defaultLayout prop of the parent Group instead.\ndisabled?: booleanWhen disabled, a panel cannot be resized either directly or indirectly (by resizing another panel).\nelementRef?: Ref<HTMLDivElement | null>Ref attached to the root HTMLDivElement.\ngroupResizeBehavior?: \"preserve-relative-size\" | \"preserve-pixel-size\" = \"preserve-relative-size\"How should this Panel behave if the parent Group is resized?\nDefaults to preserve-relative-size.\n\npreserve-relative-size: Retain the current relative size (as a percentage of the Group)\npreserve-pixel-size: Retain its current size (in pixels)\n\nPanel min/max size constraints may impact this behavior.\nA Group must contain at least one Panel with preserve-relative-size resize behavior.\nid?: string | numberUniquely identifies this panel within the parent group.\nFalls back to useId when not provided.\nThis prop is used to associate persisted group layouts with the original panel.\nThis value will also be assigned to the data-panel attribute.\nmaxSize?: string | number = \"100%\"Maximum size of Panel within its parent group; defaults to 100%.\nminSize?: string | number = \"0%\"Minimum size of Panel within its parent group; defaults to 0%.\nonResize?: ((panelSize: PanelSize, id: string | number, prevPanelSize: PanelSize | undefined) => void) | undefinedCalled when panel sizes change.\n\npanelSize Panel size (both as a percentage of the parent Group and in pixels)\nid Panel id (if one was provided as a prop)\nprevPanelSize Previous panel size (will be undefined on mount)\n\npanelRef?: Ref<PanelImperativeHandle | null>Exposes the following imperative API:\n\ncollapse(): void\nexpand(): void\ngetSize(): number\nisCollapsed(): boolean\nresize(size: number): void\n\nThe usePanelRef and usePanelCallbackRef hooks are exported for convenience use in TypeScript projects.\nstyle?: CSSPropertiesCSS properties.\nStyle is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\n",
326-
"n": 0.047
325+
"v": "A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior.\nPanel size props can be in the following formats:\n\nPercentage of the parent Group (0..100)\nPixels\nRelative font units (em, rem)\nViewport relative units (vh, vw)\n\nNumeric values are assumed to be pixels.\nStrings without explicit units are assumed to be percentages (0%..100%).\nPercentages may also be specified as strings ending with \"%\" (e.g. \"33%\")\nPixels may also be specified as strings ending with the unit \"px\".\nOther units should be specified as strings ending with their CSS property units (e.g. 1rem, 50vh)\nPanel elements always include the following attributes:\n<div data-panel data-testid=\"panel-id-prop\" id=\"panel-id-prop\">Test id can be used to narrow selection when unit testing.\nPanel elements must be direct DOM children of their parent Group elements.\n Optional propsclassName?: stringCSS class name.\nClass is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\ncollapsedSize?: string | number = \"0%\"Panel size when collapsed; defaults to 0%.\ncollapsible?: boolean = falseThis panel can be collapsed.\nA collapsible panel will collapse when it's size is less than of the specified minSize\ndefaultSize?: string | numberDefault size of Panel within its parent group; default is auto-assigned based on the total number of Panels.\nPercentage based sizes may cause slight layout shift when server-rendering.\nFor more information see the documentation.\ndisabled?: booleanWhen disabled, a panel cannot be resized either directly or indirectly (by resizing another panel).\nelementRef?: Ref<HTMLDivElement | null>Ref attached to the root HTMLDivElement.\ngroupResizeBehavior?: \"preserve-relative-size\" | \"preserve-pixel-size\" = \"preserve-relative-size\"How should this Panel behave if the parent Group is resized?\nDefaults to preserve-relative-size.\n\npreserve-relative-size: Retain the current relative size (as a percentage of the Group)\npreserve-pixel-size: Retain its current size (in pixels)\n\nPanel min/max size constraints may impact this behavior.\nA Group must contain at least one Panel with preserve-relative-size resize behavior.\nid?: string | numberUniquely identifies this panel within the parent group.\nFalls back to useId when not provided.\nThis prop is used to associate persisted group layouts with the original panel.\nThis value will also be assigned to the data-panel attribute.\nmaxSize?: string | number = \"100%\"Maximum size of Panel within its parent group; defaults to 100%.\nminSize?: string | number = \"0%\"Minimum size of Panel within its parent group; defaults to 0%.\nonResize?: ((panelSize: PanelSize, id: string | number, prevPanelSize: PanelSize | undefined) => void) | undefinedCalled when panel sizes change.\n\npanelSize Panel size (both as a percentage of the parent Group and in pixels)\nid Panel id (if one was provided as a prop)\nprevPanelSize Previous panel size (will be undefined on mount)\n\npanelRef?: Ref<PanelImperativeHandle | null>Exposes the following imperative API:\n\ncollapse(): void\nexpand(): void\ngetSize(): number\nisCollapsed(): boolean\nresize(size: number): void\n\nThe usePanelRef and usePanelCallbackRef hooks are exported for convenience use in TypeScript projects.\nstyle?: CSSPropertiesCSS properties.\nStyle is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\n",
326+
"n": 0.048
327327
}
328328
}
329329
},

0 commit comments

Comments
 (0)