Skip to content

Commit 0ca6c23

Browse files
committed
feat: Added the box option
Closes #31 Fixes #57
1 parent 8afc8f6 commit 0ca6c23

File tree

4 files changed

+812
-693
lines changed

4 files changed

+812
-693
lines changed

.size-limit.json

+3-18
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,17 @@
11
[
22
{
33
"path": "dist/bundle.esm.js",
4-
"limit": "495 B",
4+
"limit": "645B",
55
"gzip": true
66
},
7-
{
8-
"path": "dist/bundle.esm.js",
9-
"limit": "396 B",
10-
"brotli": true
11-
},
127
{
138
"path": "dist/bundle.cjs.js",
14-
"limit": "480 B",
9+
"limit": "621B",
1510
"gzip": true
1611
},
17-
{
18-
"path": "dist/bundle.cjs.js",
19-
"limit": "385 B",
20-
"brotli": true
21-
},
2212
{
2313
"path": "polyfilled.js",
24-
"limit": "2808 B",
14+
"limit": "3384B",
2515
"gzip": true
26-
},
27-
{
28-
"path": "polyfilled.js",
29-
"limit": "2510 B",
30-
"brotli": true
3116
}
3217
]

package.json

+16-5
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,27 @@
3232
"src:watch": "rollup -c -w",
3333
"check:size": "size-limit",
3434
"check:types": "tsc -p tests",
35-
"test": "run-s 'build' 'check:size' 'check:types' 'test:create:ssr' 'test:bs:*'",
35+
"test": "run-s 'build' 'check:size' 'check:types' 'test:create:ssr' 'test:headless:chrome'",
3636
"test:create:ssr": "node ./tests/ssr/create-ssr-test.js",
3737
"test:chrome": "KARMA_BROWSERS=Chrome yarn karma:run",
3838
"test:headless:chrome": "KARMA_BROWSERS=ChromeHeadless yarn karma:run",
3939
"test:firefox": "KARMA_BROWSERS=Firefox yarn karma:run",
4040
"test:headless:firefox": "KARMA_BROWSERS=FirefoxHeadless yarn karma:run",
4141
"karma:run": "karma start --singleRun",
42-
"karma:watch": "karma start",
42+
"karma:watch": "KARMA_BROWSERS=Chrome karma start",
4343
"prepublish": "yarn build",
44-
"test:bs:modern": "yarn karma:run --useBrowserStack",
45-
"test:bs:ie": "yarn karma:run --useBrowserStack --runIeTests",
44+
"test:bs:all": "run-s 'test:bs:modern' 'test:bs:legacy'",
45+
"test:bs:modern": "KARMA_BROWSERS=modern yarn karma:run",
46+
"test:bs:legacy": "KARMA_BROWSERS=legacy yarn karma:run",
47+
"test:bs:chrome": "KARMA_BROWSERS=bs_chrome_latest yarn karma:run",
48+
"test:bs:firefox": "KARMA_BROWSERS=bs_firefox_latest yarn karma:run",
49+
"test:bs:safari": "KARMA_BROWSERS=bs_safari_13 yarn karma:run",
50+
"test:bs:edge": "KARMA_BROWSERS=bs_edge_latest yarn karma:run",
51+
"test:bs:opera": "KARMA_BROWSERS=bs_opera_latest yarn karma:run",
52+
"test:bs:ie": "KARMA_BROWSERS=bs_ie_11 yarn karma:run",
53+
"test:bs:ios_11": "KARMA_BROWSERS=bs_ios_11 yarn karma:run",
54+
"test:bs:ios_14": "KARMA_BROWSERS=bs_ios_14 yarn karma:run",
55+
"test:bs:samsung": "KARMA_BROWSERS=bs_samsung yarn karma:run",
4656
"prepare": "husky install"
4757
},
4858
"lint-staged": {
@@ -103,7 +113,8 @@
103113
"rollup": "^2.6.1",
104114
"semantic-release": "^17.2.2",
105115
"size-limit": "^5.0.1",
106-
"typescript": "^4.0.3"
116+
"typescript": "^4.3.5",
117+
"webpack": "~4"
107118
},
108119
"dependencies": {
109120
"@juggle/resize-observer": "^3.3.1"

src/index.ts

+161-54
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@ function useResolvedElement<T extends HTMLElement>(
2020
refOrElement?: T | RefObject<T> | null
2121
): RefCallback<T> {
2222
const callbackRefElement = useRef<T | null>(null);
23-
const refCallback = useCallback<RefCallback<T>>((element) => {
24-
callbackRefElement.current = element;
25-
callSubscriber();
26-
}, []);
27-
const lastReportedElementRef = useRef<T | null>(null);
23+
const lastReportRef = useRef<{
24+
reporter: () => void;
25+
element: T | null;
26+
} | null>(null);
2827
const cleanupRef = useRef<SubscriberResponse | null>();
2928

30-
const callSubscriber = () => {
29+
const callSubscriber = useCallback(() => {
3130
let element = null;
3231
if (callbackRefElement.current) {
3332
element = callbackRefElement.current;
@@ -39,7 +38,11 @@ function useResolvedElement<T extends HTMLElement>(
3938
}
4039
}
4140

42-
if (lastReportedElementRef.current === element) {
41+
if (
42+
lastReportRef.current &&
43+
lastReportRef.current.element === element &&
44+
lastReportRef.current.reporter === callSubscriber
45+
) {
4346
return;
4447
}
4548

@@ -48,26 +51,34 @@ function useResolvedElement<T extends HTMLElement>(
4851
// Making sure the cleanup is not called accidentally multiple times.
4952
cleanupRef.current = null;
5053
}
51-
lastReportedElementRef.current = element;
54+
lastReportRef.current = {
55+
reporter: callSubscriber,
56+
element,
57+
};
5258

5359
// Only calling the subscriber, if there's an actual element to report.
5460
if (element) {
5561
cleanupRef.current = subscriber(element);
5662
}
57-
};
63+
}, [refOrElement, subscriber]);
5864

5965
// On each render, we check whether a ref changed, or if we got a new raw
6066
// element.
6167
useEffect(() => {
62-
// Note that this does not mean that "element" will necessarily be whatever
63-
// the ref currently holds. It'll simply "update" `element` each render to
64-
// the current ref value, but there's no guarantee that the ref value will
65-
// not change later without a render.
66-
// This may or may not be a problem depending on the specific use case.
68+
// With this we're *technically* supporting cases where ref objects' current value changes, but only if there's a
69+
// render accompanying that change as well.
70+
// To guarantee we always have the right element, one must use the ref callback provided instead, but we support
71+
// RefObjects to make the hook API more convenient in certain cases.
6772
callSubscriber();
68-
}, [refOrElement]);
73+
}, [callSubscriber]);
6974

70-
return refCallback;
75+
return useCallback<RefCallback<T>>(
76+
(element) => {
77+
callbackRefElement.current = element;
78+
callSubscriber();
79+
},
80+
[callSubscriber]
81+
);
7182
}
7283

7384
type ObservedSize = {
@@ -81,21 +92,95 @@ type HookResponse<T extends HTMLElement> = {
8192
ref: RefCallback<T>;
8293
} & ObservedSize;
8394

95+
// Declaring my own type here instead of using the one provided by TS (available since 4.2.2), because this way I'm not
96+
// forcing consumers to use a specific TS version.
97+
type ResizeObserverBoxOptions =
98+
| "border-box"
99+
| "content-box"
100+
| "device-pixel-content-box";
101+
102+
declare global {
103+
interface ResizeObserverEntry {
104+
readonly devicePixelContentBoxSize: ReadonlyArray<ResizeObserverSize>;
105+
}
106+
}
107+
108+
// We're only using the first element of the size sequences, until future versions of the spec solidify on how
109+
// exactly it'll be used for fragments in multi-column scenarios:
110+
// From the spec:
111+
// > The box size properties are exposed as FrozenArray in order to support elements that have multiple fragments,
112+
// > which occur in multi-column scenarios. However the current definitions of content rect and border box do not
113+
// > mention how those boxes are affected by multi-column layout. In this spec, there will only be a single
114+
// > ResizeObserverSize returned in the FrozenArray, which will correspond to the dimensions of the first column.
115+
// > A future version of this spec will extend the returned FrozenArray to contain the per-fragment size information.
116+
// (https://drafts.csswg.org/resize-observer/#resize-observer-entry-interface)
117+
//
118+
// Also, testing these new box options revealed that in both Chrome and FF everything is returned in the callback,
119+
// regardless of the "box" option.
120+
// The spec states the following on this:
121+
// > This does not have any impact on which box dimensions are returned to the defined callback when the event
122+
// > is fired, it solely defines which box the author wishes to observe layout changes on.
123+
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
124+
// I'm not exactly clear on what this means, especially when you consider a later section stating the following:
125+
// > This section is non-normative. An author may desire to observe more than one CSS box.
126+
// > In this case, author will need to use multiple ResizeObservers.
127+
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
128+
// Which is clearly not how current browser implementations behave, and seems to contradict the previous quote.
129+
// For this reason I decided to only return the requested size,
130+
// even though it seems we have access to results for all box types.
131+
// This also means that we get to keep the current api, being able to return a simple { width, height } pair,
132+
// regardless of box option.
133+
const extractSize = (
134+
entry: ResizeObserverEntry,
135+
boxProp: "borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize",
136+
sizeType: keyof ResizeObserverSize
137+
): number | undefined => {
138+
if (!entry[boxProp]) {
139+
if (boxProp === "contentBoxSize") {
140+
// The dimensions in `contentBoxSize` and `contentRect` are equivalent according to the spec.
141+
// See the 6th step in the description for the RO algorithm:
142+
// https://drafts.csswg.org/resize-observer/#create-and-populate-resizeobserverentry-h
143+
// > Set this.contentRect to logical this.contentBoxSize given target and observedBox of "content-box".
144+
// In real browser implementations of course these objects differ, but the width/height values should be equivalent.
145+
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
146+
}
147+
148+
return undefined;
149+
}
150+
151+
// A couple bytes smaller than calling Array.isArray() and just as effective here.
152+
return entry[boxProp][0]
153+
? entry[boxProp][0][sizeType]
154+
: // TS complains about this, because the RO entry type follows the spec and does not reflect Firefox's current
155+
// behaviour of returning objects instead of arrays for `borderBoxSize` and `contentBoxSize`.
156+
// @ts-ignore
157+
entry[boxProp][sizeType];
158+
};
159+
160+
type RoundingFunction = (n: number) => number;
161+
84162
function useResizeObserver<T extends HTMLElement>(
85163
opts: {
86164
ref?: RefObject<T> | T | null | undefined;
87165
onResize?: ResizeHandler;
166+
box?: ResizeObserverBoxOptions;
167+
round?: RoundingFunction;
88168
} = {}
89169
): HookResponse<T> {
90170
// Saving the callback as a ref. With this, I don't need to put onResize in the
91171
// effect dep array, and just passing in an anonymous function without memoising
92-
// will not reinstantiate the hook's ResizeObserver
172+
// will not reinstantiate the hook's ResizeObserver.
93173
const onResize = opts.onResize;
94174
const onResizeRef = useRef<ResizeHandler | undefined>(undefined);
95175
onResizeRef.current = onResize;
176+
const round = opts.round || Math.round;
96177

97178
// Using a single instance throughout the hook's lifetime
98-
const resizeObserverRef = useRef<ResizeObserver>();
179+
const resizeObserverRef = useRef<{
180+
box?: ResizeObserverBoxOptions;
181+
round?: RoundingFunction;
182+
instance: ResizeObserver;
183+
}>();
99184

100185
const [size, setSize] = useState<{
101186
width?: number;
@@ -114,7 +199,7 @@ function useResizeObserver<T extends HTMLElement>(
114199
};
115200
}, []);
116201

117-
// Using a ref to track the previous width / height to avoid unnecessary renders
202+
// Using a ref to track the previous width / height to avoid unnecessary renders.
118203
const previous: {
119204
current: {
120205
width?: number;
@@ -128,46 +213,68 @@ function useResizeObserver<T extends HTMLElement>(
128213
// This block is kinda like a useEffect, only it's called whenever a new
129214
// element could be resolved based on the ref option. It also has a cleanup
130215
// function.
131-
const refCallback = useResolvedElement<T>((element) => {
132-
// Initialising the RO instance
133-
if (!resizeObserverRef.current) {
134-
// Saving a single instance, used by the hook from this point on.
135-
resizeObserverRef.current = new ResizeObserver((entries) => {
136-
if (!Array.isArray(entries)) {
137-
return;
138-
}
139-
140-
const entry = entries[0];
141-
142-
// `Math.round` is in line with how CSS resolves sub-pixel values
143-
const newWidth = Math.round(entry.contentRect.width);
144-
const newHeight = Math.round(entry.contentRect.height);
216+
const refCallback = useResolvedElement<T>(
217+
useCallback(
218+
(element) => {
219+
// We only use a single Resize Observer instance, and we're instantiating it on demand, only once there's something to observe.
220+
// This instance is also recreated when the `box` option changes, so that a new observation is fired if there was a previously observed element with a different box option.
145221
if (
146-
previous.current.width !== newWidth ||
147-
previous.current.height !== newHeight
222+
!resizeObserverRef.current ||
223+
resizeObserverRef.current.box !== opts.box ||
224+
resizeObserverRef.current.round !== round
148225
) {
149-
const newSize = { width: newWidth, height: newHeight };
150-
if (onResizeRef.current) {
151-
onResizeRef.current(newSize);
152-
} else {
153-
previous.current.width = newWidth;
154-
previous.current.height = newHeight;
155-
if (!didUnmount.current) {
156-
setSize(newSize);
157-
}
158-
}
226+
resizeObserverRef.current = {
227+
box: opts.box,
228+
round,
229+
instance: new ResizeObserver((entries) => {
230+
const entry = entries[0];
231+
232+
const boxProp =
233+
opts.box === "border-box"
234+
? "borderBoxSize"
235+
: opts.box === "device-pixel-content-box"
236+
? "devicePixelContentBoxSize"
237+
: "contentBoxSize";
238+
239+
const reportedWidth = extractSize(entry, boxProp, "inlineSize");
240+
const reportedHeight = extractSize(entry, boxProp, "blockSize");
241+
242+
const newWidth = reportedWidth ? round(reportedWidth) : undefined;
243+
const newHeight = reportedHeight
244+
? round(reportedHeight)
245+
: undefined;
246+
247+
if (
248+
previous.current.width !== newWidth ||
249+
previous.current.height !== newHeight
250+
) {
251+
const newSize = { width: newWidth, height: newHeight };
252+
previous.current.width = newWidth;
253+
previous.current.height = newHeight;
254+
if (onResizeRef.current) {
255+
onResizeRef.current(newSize);
256+
} else {
257+
if (!didUnmount.current) {
258+
setSize(newSize);
259+
}
260+
}
261+
}
262+
}),
263+
};
159264
}
160-
});
161-
}
162265

163-
resizeObserverRef.current.observe(element);
266+
resizeObserverRef.current.instance.observe(element, { box: opts.box });
164267

165-
return () => {
166-
if (resizeObserverRef.current) {
167-
resizeObserverRef.current.unobserve(element);
168-
}
169-
};
170-
}, opts.ref);
268+
return () => {
269+
if (resizeObserverRef.current) {
270+
resizeObserverRef.current.instance.unobserve(element);
271+
}
272+
};
273+
},
274+
[opts.box, round]
275+
),
276+
opts.ref
277+
);
171278

172279
return useMemo(
173280
() => ({

0 commit comments

Comments
 (0)