Skip to content

Commit

Permalink
feat: Added the box option
Browse files Browse the repository at this point in the history
Closes #31
Fixes #57
  • Loading branch information
ZeeCoder committed Aug 28, 2021
1 parent 8afc8f6 commit 0ca6c23
Show file tree
Hide file tree
Showing 4 changed files with 812 additions and 693 deletions.
21 changes: 3 additions & 18 deletions .size-limit.json
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
[
{
"path": "dist/bundle.esm.js",
"limit": "495 B",
"limit": "645B",
"gzip": true
},
{
"path": "dist/bundle.esm.js",
"limit": "396 B",
"brotli": true
},
{
"path": "dist/bundle.cjs.js",
"limit": "480 B",
"limit": "621B",
"gzip": true
},
{
"path": "dist/bundle.cjs.js",
"limit": "385 B",
"brotli": true
},
{
"path": "polyfilled.js",
"limit": "2808 B",
"limit": "3384B",
"gzip": true
},
{
"path": "polyfilled.js",
"limit": "2510 B",
"brotli": true
}
]
21 changes: 16 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,27 @@
"src:watch": "rollup -c -w",
"check:size": "size-limit",
"check:types": "tsc -p tests",
"test": "run-s 'build' 'check:size' 'check:types' 'test:create:ssr' 'test:bs:*'",
"test": "run-s 'build' 'check:size' 'check:types' 'test:create:ssr' 'test:headless:chrome'",
"test:create:ssr": "node ./tests/ssr/create-ssr-test.js",
"test:chrome": "KARMA_BROWSERS=Chrome yarn karma:run",
"test:headless:chrome": "KARMA_BROWSERS=ChromeHeadless yarn karma:run",
"test:firefox": "KARMA_BROWSERS=Firefox yarn karma:run",
"test:headless:firefox": "KARMA_BROWSERS=FirefoxHeadless yarn karma:run",
"karma:run": "karma start --singleRun",
"karma:watch": "karma start",
"karma:watch": "KARMA_BROWSERS=Chrome karma start",
"prepublish": "yarn build",
"test:bs:modern": "yarn karma:run --useBrowserStack",
"test:bs:ie": "yarn karma:run --useBrowserStack --runIeTests",
"test:bs:all": "run-s 'test:bs:modern' 'test:bs:legacy'",
"test:bs:modern": "KARMA_BROWSERS=modern yarn karma:run",
"test:bs:legacy": "KARMA_BROWSERS=legacy yarn karma:run",
"test:bs:chrome": "KARMA_BROWSERS=bs_chrome_latest yarn karma:run",
"test:bs:firefox": "KARMA_BROWSERS=bs_firefox_latest yarn karma:run",
"test:bs:safari": "KARMA_BROWSERS=bs_safari_13 yarn karma:run",
"test:bs:edge": "KARMA_BROWSERS=bs_edge_latest yarn karma:run",
"test:bs:opera": "KARMA_BROWSERS=bs_opera_latest yarn karma:run",
"test:bs:ie": "KARMA_BROWSERS=bs_ie_11 yarn karma:run",
"test:bs:ios_11": "KARMA_BROWSERS=bs_ios_11 yarn karma:run",
"test:bs:ios_14": "KARMA_BROWSERS=bs_ios_14 yarn karma:run",
"test:bs:samsung": "KARMA_BROWSERS=bs_samsung yarn karma:run",
"prepare": "husky install"
},
"lint-staged": {
Expand Down Expand Up @@ -103,7 +113,8 @@
"rollup": "^2.6.1",
"semantic-release": "^17.2.2",
"size-limit": "^5.0.1",
"typescript": "^4.0.3"
"typescript": "^4.3.5",
"webpack": "~4"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1"
Expand Down
215 changes: 161 additions & 54 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ function useResolvedElement<T extends HTMLElement>(
refOrElement?: T | RefObject<T> | null
): RefCallback<T> {
const callbackRefElement = useRef<T | null>(null);
const refCallback = useCallback<RefCallback<T>>((element) => {
callbackRefElement.current = element;
callSubscriber();
}, []);
const lastReportedElementRef = useRef<T | null>(null);
const lastReportRef = useRef<{
reporter: () => void;
element: T | null;
} | null>(null);
const cleanupRef = useRef<SubscriberResponse | null>();

const callSubscriber = () => {
const callSubscriber = useCallback(() => {
let element = null;
if (callbackRefElement.current) {
element = callbackRefElement.current;
Expand All @@ -39,7 +38,11 @@ function useResolvedElement<T extends HTMLElement>(
}
}

if (lastReportedElementRef.current === element) {
if (
lastReportRef.current &&
lastReportRef.current.element === element &&
lastReportRef.current.reporter === callSubscriber
) {
return;
}

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

// Only calling the subscriber, if there's an actual element to report.
if (element) {
cleanupRef.current = subscriber(element);
}
};
}, [refOrElement, subscriber]);

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

return refCallback;
return useCallback<RefCallback<T>>(
(element) => {
callbackRefElement.current = element;
callSubscriber();
},
[callSubscriber]
);
}

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

// Declaring my own type here instead of using the one provided by TS (available since 4.2.2), because this way I'm not
// forcing consumers to use a specific TS version.
type ResizeObserverBoxOptions =
| "border-box"
| "content-box"
| "device-pixel-content-box";

declare global {
interface ResizeObserverEntry {
readonly devicePixelContentBoxSize: ReadonlyArray<ResizeObserverSize>;
}
}

// We're only using the first element of the size sequences, until future versions of the spec solidify on how
// exactly it'll be used for fragments in multi-column scenarios:
// From the spec:
// > The box size properties are exposed as FrozenArray in order to support elements that have multiple fragments,
// > which occur in multi-column scenarios. However the current definitions of content rect and border box do not
// > mention how those boxes are affected by multi-column layout. In this spec, there will only be a single
// > ResizeObserverSize returned in the FrozenArray, which will correspond to the dimensions of the first column.
// > A future version of this spec will extend the returned FrozenArray to contain the per-fragment size information.
// (https://drafts.csswg.org/resize-observer/#resize-observer-entry-interface)
//
// Also, testing these new box options revealed that in both Chrome and FF everything is returned in the callback,
// regardless of the "box" option.
// The spec states the following on this:
// > This does not have any impact on which box dimensions are returned to the defined callback when the event
// > is fired, it solely defines which box the author wishes to observe layout changes on.
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
// I'm not exactly clear on what this means, especially when you consider a later section stating the following:
// > This section is non-normative. An author may desire to observe more than one CSS box.
// > In this case, author will need to use multiple ResizeObservers.
// (https://drafts.csswg.org/resize-observer/#resize-observer-interface)
// Which is clearly not how current browser implementations behave, and seems to contradict the previous quote.
// For this reason I decided to only return the requested size,
// even though it seems we have access to results for all box types.
// This also means that we get to keep the current api, being able to return a simple { width, height } pair,
// regardless of box option.
const extractSize = (
entry: ResizeObserverEntry,
boxProp: "borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize",
sizeType: keyof ResizeObserverSize
): number | undefined => {
if (!entry[boxProp]) {
if (boxProp === "contentBoxSize") {
// The dimensions in `contentBoxSize` and `contentRect` are equivalent according to the spec.
// See the 6th step in the description for the RO algorithm:
// https://drafts.csswg.org/resize-observer/#create-and-populate-resizeobserverentry-h
// > Set this.contentRect to logical this.contentBoxSize given target and observedBox of "content-box".
// In real browser implementations of course these objects differ, but the width/height values should be equivalent.
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
}

return undefined;
}

// A couple bytes smaller than calling Array.isArray() and just as effective here.
return entry[boxProp][0]
? entry[boxProp][0][sizeType]
: // TS complains about this, because the RO entry type follows the spec and does not reflect Firefox's current
// behaviour of returning objects instead of arrays for `borderBoxSize` and `contentBoxSize`.
// @ts-ignore
entry[boxProp][sizeType];
};

type RoundingFunction = (n: number) => number;

function useResizeObserver<T extends HTMLElement>(
opts: {
ref?: RefObject<T> | T | null | undefined;
onResize?: ResizeHandler;
box?: ResizeObserverBoxOptions;
round?: RoundingFunction;
} = {}
): HookResponse<T> {
// Saving the callback as a ref. With this, I don't need to put onResize in the
// effect dep array, and just passing in an anonymous function without memoising
// will not reinstantiate the hook's ResizeObserver
// will not reinstantiate the hook's ResizeObserver.
const onResize = opts.onResize;
const onResizeRef = useRef<ResizeHandler | undefined>(undefined);
onResizeRef.current = onResize;
const round = opts.round || Math.round;

// Using a single instance throughout the hook's lifetime
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserverRef = useRef<{
box?: ResizeObserverBoxOptions;
round?: RoundingFunction;
instance: ResizeObserver;
}>();

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

// Using a ref to track the previous width / height to avoid unnecessary renders
// Using a ref to track the previous width / height to avoid unnecessary renders.
const previous: {
current: {
width?: number;
Expand All @@ -128,46 +213,68 @@ function useResizeObserver<T extends HTMLElement>(
// This block is kinda like a useEffect, only it's called whenever a new
// element could be resolved based on the ref option. It also has a cleanup
// function.
const refCallback = useResolvedElement<T>((element) => {
// Initialising the RO instance
if (!resizeObserverRef.current) {
// Saving a single instance, used by the hook from this point on.
resizeObserverRef.current = new ResizeObserver((entries) => {
if (!Array.isArray(entries)) {
return;
}

const entry = entries[0];

// `Math.round` is in line with how CSS resolves sub-pixel values
const newWidth = Math.round(entry.contentRect.width);
const newHeight = Math.round(entry.contentRect.height);
const refCallback = useResolvedElement<T>(
useCallback(
(element) => {
// We only use a single Resize Observer instance, and we're instantiating it on demand, only once there's something to observe.
// 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.
if (
previous.current.width !== newWidth ||
previous.current.height !== newHeight
!resizeObserverRef.current ||
resizeObserverRef.current.box !== opts.box ||
resizeObserverRef.current.round !== round
) {
const newSize = { width: newWidth, height: newHeight };
if (onResizeRef.current) {
onResizeRef.current(newSize);
} else {
previous.current.width = newWidth;
previous.current.height = newHeight;
if (!didUnmount.current) {
setSize(newSize);
}
}
resizeObserverRef.current = {
box: opts.box,
round,
instance: new ResizeObserver((entries) => {
const entry = entries[0];

const boxProp =
opts.box === "border-box"
? "borderBoxSize"
: opts.box === "device-pixel-content-box"
? "devicePixelContentBoxSize"
: "contentBoxSize";

const reportedWidth = extractSize(entry, boxProp, "inlineSize");
const reportedHeight = extractSize(entry, boxProp, "blockSize");

const newWidth = reportedWidth ? round(reportedWidth) : undefined;
const newHeight = reportedHeight
? round(reportedHeight)
: undefined;

if (
previous.current.width !== newWidth ||
previous.current.height !== newHeight
) {
const newSize = { width: newWidth, height: newHeight };
previous.current.width = newWidth;
previous.current.height = newHeight;
if (onResizeRef.current) {
onResizeRef.current(newSize);
} else {
if (!didUnmount.current) {
setSize(newSize);
}
}
}
}),
};
}
});
}

resizeObserverRef.current.observe(element);
resizeObserverRef.current.instance.observe(element, { box: opts.box });

return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.unobserve(element);
}
};
}, opts.ref);
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.instance.unobserve(element);
}
};
},
[opts.box, round]
),
opts.ref
);

return useMemo(
() => ({
Expand Down
Loading

0 comments on commit 0ca6c23

Please sign in to comment.