diff --git a/package.json b/package.json index 9c6dbe5e..066df29b 100644 --- a/package.json +++ b/package.json @@ -53,4 +53,4 @@ "eslint --fix" ] } -} +} \ No newline at end of file diff --git a/packages/notion-client/package.json b/packages/notion-client/package.json index 411225bb..82e43d05 100644 --- a/packages/notion-client/package.json +++ b/packages/notion-client/package.json @@ -27,7 +27,7 @@ "test:unit": "vitest run" }, "dependencies": { - "ky": "catalog:", + "ofetch": "catalog:", "notion-types": "workspace:*", "notion-utils": "workspace:*", "p-map": "catalog:" diff --git a/packages/notion-client/src/notion-api.ts b/packages/notion-client/src/notion-api.ts index 477ee0e2..3556d159 100644 --- a/packages/notion-client/src/notion-api.ts +++ b/packages/notion-client/src/notion-api.ts @@ -1,12 +1,14 @@ // import { promises as fs } from 'fs' +//import ky, { type Options as OfetchOptions } from 'ky' + import type * as notion from 'notion-types' -import ky, { type Options as KyOptions } from 'ky' import { getBlockCollectionId, getPageContentBlockIds, parsePageId, uuidToId } from 'notion-utils' +import { type FetchOptions as OfetchOptions, ofetch } from 'ofetch' import pMap from 'p-map' import type * as types from './types' @@ -19,27 +21,27 @@ export class NotionAPI { private readonly _authToken?: string private readonly _activeUser?: string private readonly _userTimeZone: string - private readonly _kyOptions?: KyOptions + private readonly _ofetchOptions?: OfetchOptions constructor({ apiBaseUrl = 'https://www.notion.so/api/v3', authToken, activeUser, userTimeZone = 'America/New_York', - kyOptions + ofetchOptions }: { apiBaseUrl?: string authToken?: string userLocale?: string userTimeZone?: string activeUser?: string - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions } = {}) { this._apiBaseUrl = apiBaseUrl this._authToken = authToken this._activeUser = activeUser this._userTimeZone = userTimeZone - this._kyOptions = kyOptions + this._ofetchOptions = ofetchOptions } public async getPage( @@ -54,7 +56,7 @@ export class NotionAPI { throwOnCollectionErrors = false, collectionReducerLimit = 999, fetchRelationPages = false, - kyOptions + ofetchOptions }: { concurrency?: number fetchMissingBlocks?: boolean @@ -65,13 +67,13 @@ export class NotionAPI { throwOnCollectionErrors?: boolean collectionReducerLimit?: number fetchRelationPages?: boolean - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions } = {} ): Promise { const page = await this.getPageRaw(pageId, { chunkLimit, chunkNumber, - kyOptions + ofetchOptions }) const recordMap = page?.recordMap as notion.ExtendedRecordMap @@ -100,9 +102,10 @@ export class NotionAPI { break } - const newBlocks = await this.getBlocks(pendingBlockIds, kyOptions).then( - (res) => res.recordMap.block - ) + const newBlocks = await this.getBlocks( + pendingBlockIds, + ofetchOptions + ).then((res) => res.recordMap.block) recordMap.block = { ...recordMap.block, ...newBlocks } } @@ -152,7 +155,7 @@ export class NotionAPI { collectionView, { limit: collectionReducerLimit, - kyOptions + ofetchOptions } ) @@ -219,11 +222,11 @@ export class NotionAPI { // because it is preferable for many use cases as opposed to making these API calls // lazily from the client-side. if (signFileUrls) { - await this.addSignedUrls({ recordMap, contentBlockIds, kyOptions }) + await this.addSignedUrls({ recordMap, contentBlockIds, ofetchOptions }) } if (fetchRelationPages) { - const newBlocks = await this.fetchRelationPages(recordMap, kyOptions) + const newBlocks = await this.fetchRelationPages(recordMap, ofetchOptions) recordMap.block = { ...recordMap.block, ...newBlocks } } @@ -232,7 +235,7 @@ export class NotionAPI { fetchRelationPages = async ( recordMap: notion.ExtendedRecordMap, - kyOptions: KyOptions | undefined + ofetchOptions: OfetchOptions | undefined ): Promise => { const maxIterations = 10 @@ -265,7 +268,7 @@ export class NotionAPI { try { const newBlocks = await this.getBlocks( missingRelationPageIds, - kyOptions + ofetchOptions ).then((res) => res.recordMap.block) recordMap.block = { ...recordMap.block, ...newBlocks } } catch (err: any) { @@ -316,11 +319,11 @@ export class NotionAPI { public async addSignedUrls({ recordMap, contentBlockIds, - kyOptions = {} + ofetchOptions = {} }: { recordMap: notion.ExtendedRecordMap contentBlockIds?: string[] - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions }) { recordMap.signed_urls = {} @@ -372,7 +375,7 @@ export class NotionAPI { try { const { signedUrls } = await this.getSignedFileUrls( allFileInstances, - kyOptions + ofetchOptions ) if (signedUrls.length === allFileInstances.length) { @@ -395,13 +398,13 @@ export class NotionAPI { public async getPageRaw( pageId: string, { - kyOptions, + ofetchOptions, chunkLimit = 100, chunkNumber = 0 }: { chunkLimit?: number chunkNumber?: number - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions } = {} ) { const parsedPageId = parsePageId(pageId) @@ -421,7 +424,7 @@ export class NotionAPI { return this.fetch({ endpoint: 'loadPageChunk', body, - kyOptions + ofetchOptions }) } @@ -434,7 +437,7 @@ export class NotionAPI { searchQuery = '', userTimeZone = this._userTimeZone, loadContentCover = true, - kyOptions + ofetchOptions }: { type?: notion.CollectionViewType limit?: number @@ -442,7 +445,7 @@ export class NotionAPI { userTimeZone?: string userLocale?: string loadContentCover?: boolean - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions } = {} ) { const type = collectionView?.type @@ -626,28 +629,28 @@ export class NotionAPI { }, loader }, - kyOptions: { + ofetchOptions: { timeout: 60_000, - ...kyOptions, - searchParams: { - // TODO: spread kyOptions?.searchParams + ...ofetchOptions, + params: { + // TODO: spread ofetchOptions?.searchParams src: 'initial_load' } } }) } - public async getUsers(userIds: string[], kyOptions?: KyOptions) { + public async getUsers(userIds: string[], ofetchOptions?: OfetchOptions) { return this.fetch>({ endpoint: 'getRecordValues', body: { requests: userIds.map((id) => ({ id, table: 'notion_user' })) }, - kyOptions + ofetchOptions }) } - public async getBlocks(blockIds: string[], kyOptions?: KyOptions) { + public async getBlocks(blockIds: string[], ofetchOptions?: OfetchOptions) { return this.fetch({ endpoint: 'syncRecordValues', body: { @@ -658,24 +661,27 @@ export class NotionAPI { version: -1 })) }, - kyOptions + ofetchOptions }) } public async getSignedFileUrls( urls: types.SignedUrlRequest[], - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions ) { return this.fetch({ endpoint: 'getSignedFileUrls', body: { urls }, - kyOptions + ofetchOptions }) } - public async search(params: notion.SearchParams, kyOptions?: KyOptions) { + public async search( + params: notion.SearchParams, + ofetchOptions?: OfetchOptions + ) { const body = { type: 'BlocksInAncestor', source: 'quick_find_public', @@ -703,25 +709,25 @@ export class NotionAPI { return this.fetch({ endpoint: 'search', body, - kyOptions + ofetchOptions }) } public async fetch({ endpoint, body, - kyOptions, + ofetchOptions, headers: clientHeaders }: { endpoint: string body: object - kyOptions?: KyOptions + ofetchOptions?: OfetchOptions headers?: any }): Promise { const headers: any = { ...clientHeaders, - ...this._kyOptions?.headers, - ...kyOptions?.headers, + ...this._ofetchOptions?.headers, + ...ofetchOptions?.headers, 'Content-Type': 'application/json' } @@ -735,13 +741,13 @@ export class NotionAPI { const url = `${this._apiBaseUrl}/${endpoint}` - const res = await ky.post(url, { + /* const res = await ky.post(url, { mode: 'no-cors', - ...this._kyOptions, - ...kyOptions, + ...this._ofetchOptions, + ...ofetchOptions, json: body, headers - }) + }) */ // TODO: we're awaiting the first fetch and then separately awaiting // `res.json()` because there seems to be some weird error which repros @@ -750,6 +756,15 @@ export class NotionAPI { // steps seems to fix the issue locally for me... // console.log(endpoint, { bodyUsed: res.bodyUsed }) - return res.json() + /* return res.json() */ + const res = ofetch(url, { + method: 'POST', + mode: 'no-cors', + ...this._ofetchOptions, + ...ofetchOptions, + body, + headers + }) + return res } } diff --git a/packages/react-notion-x/package.json b/packages/react-notion-x/package.json index 9486cf32..3296f40d 100644 --- a/packages/react-notion-x/package.json +++ b/packages/react-notion-x/package.json @@ -64,8 +64,10 @@ "react-fast-compare": "catalog:", "react-hotkeys-hook": "catalog:", "react-image": "catalog:", - "react-lazy-images": "catalog:", - "react-modal": "catalog:" + "react-intersection-observer": "catalog:", + "react-modal": "catalog:", + "unionize" : "catalog:" + }, "devDependencies": { "@types/lodash.throttle": "catalog:", diff --git a/packages/react-notion-x/src/components/lazy-image-full.tsx b/packages/react-notion-x/src/components/lazy-image-full.tsx new file mode 100644 index 00000000..a82768ef --- /dev/null +++ b/packages/react-notion-x/src/components/lazy-image-full.tsx @@ -0,0 +1,429 @@ +//^^ Imported from https://github.com/fpapado/react-lazy-images/ +//^^ Edited by mustaqimarifin https://github.com/mustaqimarifin + +import { Component } from 'react' +import { InView } from 'react-intersection-observer' +import { ofType, unionize, type UnionOf } from 'unionize' + +/** + * Valid props for LazyImage components + */ +export type CommonLazyImageProps = ImageProps & { + // NOTE: if you add props here, remember to destructure them out of being + // passed to the children, in the render() callback. + + /** Whether to skip checking for viewport and always show the 'actual' component + * @see https://github.com/fpapado/react-lazy-images/#eager-loading--server-side-rendering-ssr + */ + loadEagerly?: boolean + + /** Subset of props for the IntersectionObserver + * @see https://github.com/thebuilder/react-intersection-observer#props + */ + observerProps?: ObserverProps + + /** Use the Image Decode API; + * The call to a new HTML element’s decode() function returns a promise, which, + * when fulfilled, ensures that the image can be appended to the DOM without causing + * a decoding delay on the next frame. + * @see: https://www.chromestatus.com/feature/5637156160667648 + */ + experimentalDecode?: boolean + + /** Whether to log out internal state transitions for the component */ + debugActions?: boolean + + /** Delay a certain duration before starting to load, in ms. + * This can help avoid loading images while the user scrolls quickly past them. + * TODO: naming things. + */ + debounceDurationMs?: number +} + +/** Valid props for LazyImageFull */ +export interface LazyImageFullProps extends CommonLazyImageProps { + /** Children should be either a function or a node */ + children: (args: RenderCallbackArgs) => React.ReactNode +} + +/** Values that the render props take */ +export interface RenderCallbackArgs { + imageState: ImageState + imageProps: ImageProps + /** When not loading eagerly, a ref to bind to the DOM element. This is needed for the intersection calculation to work. */ + ref?: React.RefObject | ((node?: Element | null) => void) +} + +export interface ImageProps { + /** The source of the image to load */ + src: string + + /** The source set of the image to load */ + srcSet?: string + + /** The alt text description of the image you are loading */ + alt?: string + + /** Sizes descriptor */ + sizes?: string +} + +/** Subset of react-intersection-observer's props */ +export interface ObserverProps { + /** + * Margin around the root that expands the area for intersection. + * @see https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin + * @default "50px 0px" + * @example Declaration same as CSS margin: + * `"10px 20px 30px 40px"` (top, right, bottom, left). + */ + rootMargin?: string + + /** Number between 0 and 1 indicating the the percentage that should be + * visible before triggering. + * @default `0.01` + */ + threshold?: number +} + +/** States that the image loading can be in. + * Used together with LazyImageFull render props. + * External representation of the internal state. + * */ +export enum ImageState { + NotAsked = 'NotAsked', + Loading = 'Loading', + LoadSuccess = 'LoadSuccess', + LoadError = 'LoadError' +} + +/** The component's state */ +const LazyImageFullState = unionize({ + NotAsked: {}, + Buffering: {}, + // Could try to make it Promise, + // but we don't use the element anyway, and we cache promises + Loading: {}, + LoadSuccess: {}, + LoadError: ofType<{ msg: string }>() +}) + +type LazyImageFullState = UnionOf + +/** Actions that change the component's state. + * These are not unlike Actions in Redux or, the ones I'm inspired by, + * Msg in Elm. + */ +const Action = unionize({ + ViewChanged: ofType<{ inView: boolean }>(), + BufferingEnded: {}, + // MAYBE: Load: {}, + LoadSuccess: {}, + LoadError: ofType<{ msg: string }>() +}) + +type Action = UnionOf + +/** Commands (Cmd) describe side-effects as functions that take the instance */ +// FUTURE: These should be tied to giving back a Msg / asynchronoulsy giving a Msg with conditions +type Cmd = (instance: LazyImageFull) => void + +/** The output from a reducer is the next state and maybe a command */ +type ReducerResult = { + nextState: LazyImageFullState + cmd?: Cmd +} + +///// Commands, things that perform side-effects ///// +/** Get a command that sets a buffering Promise */ +const getBufferingCmd = + (durationMs: number): Cmd => + (instance) => { + // Make cancelable buffering Promise + const bufferingPromise = makeCancelable(delayedPromise(durationMs)) + + // Kick off promise chain + bufferingPromise.promise + .then(() => instance.update(Action.BufferingEnded())) + .catch( + (_err) => {} + //console.log({ isCanceled: _reason.isCanceled }) + ) + + // Side-effect; set the promise in the cache + instance.promiseCache.buffering = bufferingPromise + } + +/** Get a command that sets an image loading Promise */ +const getLoadingCmd = + (imageProps: ImageProps, experimentalDecode?: boolean): Cmd => + (instance) => { + // Make cancelable loading Promise + const loadingPromise = makeCancelable( + loadImage(imageProps, experimentalDecode) + ) + + // Kick off request for Image and attach listeners for response + loadingPromise.promise + .then((_res) => instance.update(Action.LoadSuccess({}))) + .catch((err) => { + // If the Loading Promise was canceled, it means we have stopped + // loading due to unmount, rather than an error. + if (!err.isCanceled) { + //@ts-expect-error No need for changing structure + instance.update(new Action.LoadError({ msg: 'LoadError' })) + } + }) + + // Side-effect; set the promise in the cache + instance.promiseCache.loading = loadingPromise + } + +/** Command that cancels the buffering Promise */ +const cancelBufferingCmd: Cmd = (instance) => { + // Side-effect; cancel the promise in the cache + // We know this exists if we are in a Buffering state + instance.promiseCache.buffering?.cancel() +} + +/** + * Component that preloads the image once it is in the viewport, + * and then swaps it in. Takes a render prop that allows to specify + * what is rendered based on the loading state. + */ +export class LazyImageFull extends Component< + LazyImageFullProps, + LazyImageFullState +> { + static displayName = 'LazyImageFull' + + /** A central place to store promises. + * A bit silly, but passing promises directly in the state + * was giving me weird timing issues. This way we can keep + * the promises in check, and pick them up from the respective methods. + * FUTURE: Could pass the relevant key in Buffering and Loading, so + * that at least we know where they are from a single source. + */ + promiseCache: { + [key: string]: CancelablePromise + } = {} + + initialState = LazyImageFullState.NotAsked() + + /** Emit the next state based on actions. + * This is the core of the component! + */ + static reducer( + action: Action, + prevState: LazyImageFullState, + props: LazyImageFullProps + ): ReducerResult { + return Action.match(action, { + ViewChanged: ({ inView }) => { + if (inView === true) { + // If src is not specified, then there is nothing to preload; skip to Loaded state + if (!props.src) { + return { nextState: LazyImageFullState.LoadSuccess() } // Error wtf + } else { + // If in view, only load something if NotAsked, otherwise leave untouched + return LazyImageFullState.match(prevState, { + NotAsked: () => { + // If debounce is specified, then start buffering + if (props.debounceDurationMs) { + return { + nextState: LazyImageFullState.Buffering(), + cmd: getBufferingCmd(props.debounceDurationMs) + } + } else { + // If no debounce is specified, then start loading immediately + return { + nextState: LazyImageFullState.Loading(), + cmd: getLoadingCmd(props, props.experimentalDecode) + } + } + }, + // Do nothing in other states + default: () => ({ nextState: prevState }) + }) + } + } else { + // If out of view, cancel if Buffering, otherwise leave untouched + return LazyImageFullState.match(prevState, { + Buffering: () => ({ + nextState: LazyImageFullState.NotAsked(), + cmd: cancelBufferingCmd + }), + // Do nothing in other states + default: () => ({ nextState: prevState }) + }) + } + }, + // Buffering has ended/succeeded, kick off request for image + BufferingEnded: () => ({ + nextState: LazyImageFullState.Loading(), + cmd: getLoadingCmd(props, props.experimentalDecode) + }), + // Loading the image succeeded, simple + LoadSuccess: () => ({ nextState: LazyImageFullState.LoadSuccess() }), + //@ts-expect-error No need for changing structure + LoadError: (e) => ({ nextState: new LazyImageFullState.LoadError(e) }) + }) + } + + constructor(props: LazyImageFullProps) { + super(props) + this.state = this.initialState + + // Bind methods + this.update = this.update.bind(this) + } + + update(action: Action) { + // Get the next state and any effects + const { nextState, cmd } = LazyImageFull.reducer( + action, + this.state, + this.props + ) + + // Debugging + if (this.props.debugActions) { + if (process.env.NODE_ENV === 'production') { + console.warn( + 'You are running LazyImage with debugActions="true" in production. This might have performance implications.' + ) + } + console.log({ action, prevState: this.state, nextState }) + } + + // Actually set the state, and kick off any effects after that + this.setState(nextState, () => cmd && cmd(this)) + } + + override componentWillUnmount() { + // Clear the Promise Cache + if (this.promiseCache.loading) { + // NOTE: This does not cancel the request, only the callback. + // We we would need fetch() and an AbortHandler for that. + this.promiseCache.loading.cancel() + } + if (this.promiseCache.buffering) { + this.promiseCache.buffering.cancel() + } + this.promiseCache = {} + } + + // Render function + override render() { + // This destructuring is silly + const { children, loadEagerly, observerProps, ...imageProps } = this.props + + if (loadEagerly) { + // If eager, skip the observer and view changing stuff; resolve the imageState as loaded. + return children({ + // We know that the state tags and the enum match up + imageState: LazyImageFullState.LoadSuccess().tag as ImageState, + imageProps + }) + } else { + return ( + + this.update(Action.ViewChanged({ inView })) + } + > + {({ ref }: RenderProps) => + children({ + // We know that the state tags and the enum match up, apart + // from Buffering not being exposed + imageState: + this.state.tag === 'Buffering' + ? ImageState.Loading + : (this.state.tag as ImageState), + imageProps, + ref + }) + } + + ) + } + } +} + +interface RenderProps { + inView: boolean + entry: IntersectionObserverEntry | undefined + ref: React.RefObject | ((node?: Element | null) => void) +} + +///// Utilities ///// + +/** Promise constructor for loading an image */ +const loadImage = ( + { src, srcSet, alt, sizes }: ImageProps, + experimentalDecode = false +) => + new Promise((resolve, reject) => { + const image = new Image() + if (srcSet) { + image.srcset = srcSet + } + if (alt) { + image.alt = alt + } + if (sizes) { + image.sizes = sizes + } + image.src = src + + /** @see: https://www.chromestatus.com/feature/5637156160667648 */ + if (experimentalDecode && 'decode' in image) { + return image + .decode() + .then(() => resolve(image)) + .catch((err: any) => reject(err)) + } + + image.addEventListener('load', resolve) + image.addEventListener('error', reject) + }) + +/** Promise that resolves after a specified number of ms */ +const delayedPromise = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +interface CancelablePromise { + promise: Promise + cancel: () => void +} + +/** Make a Promise "cancelable". + * + * Rejects with {isCanceled: true} if canceled. + * + * The way this works is by wrapping it with internal hasCanceled_ state + * and checking it before resolving. + */ +const makeCancelable = (promise: Promise): CancelablePromise => { + let hasCanceled_ = false + + const wrappedPromise = new Promise((resolve, reject) => { + void promise.then((val: any) => + hasCanceled_ ? reject({ isCanceled: true }) : resolve(val) + ) + promise.catch((err: any) => + hasCanceled_ ? reject({ isCanceled: true }) : reject(err) + ) + }) + + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true + } + } +} diff --git a/packages/react-notion-x/src/components/lazy-image.tsx b/packages/react-notion-x/src/components/lazy-image.tsx index 7af234d0..b2d8209a 100644 --- a/packages/react-notion-x/src/components/lazy-image.tsx +++ b/packages/react-notion-x/src/components/lazy-image.tsx @@ -1,9 +1,9 @@ import { normalizeUrl } from 'notion-utils' import React from 'react' -import { ImageState, LazyImageFull } from 'react-lazy-images' import { useNotionContext } from '../context' import { cs } from '../utils' +import { ImageState, LazyImageFull } from './lazy-image-full' /** * Progressive, lazy images modeled after Medium's LQIP technique. @@ -82,7 +82,6 @@ export function LazyImage({ } return ( - // @ts-expect-error LazyImage types are out-of-date. {({ imageState, ref }) => { const isLoaded = imageState === ImageState.LoadSuccess diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b9dbfc1..b60463fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ catalogs: npm-run-all2: specifier: ^8.0.4 version: 8.0.4 + ofetch: + specifier: ^1.4.1 + version: 1.4.1 p-map: specifier: ^7.0.3 version: 7.0.3 @@ -120,9 +123,9 @@ catalogs: react-image: specifier: ^4.0.3 version: 4.1.0 - react-lazy-images: - specifier: ^1.1.0 - version: 1.1.0 + react-intersection-observer: + specifier: ^9.16.0 + version: 9.16.0 react-modal: specifier: ^3.16.3 version: 3.16.3 @@ -147,6 +150,9 @@ catalogs: typescript: specifier: ^5.8.3 version: 5.8.3 + unionize: + specifier: ^3.1.0 + version: 3.1.0 vitest: specifier: ^3.2.2 version: 3.2.2 @@ -315,15 +321,15 @@ importers: packages/notion-client: dependencies: - ky: - specifier: 'catalog:' - version: 1.8.1 notion-types: specifier: workspace:* version: link:../notion-types notion-utils: specifier: workspace:* version: link:../notion-utils + ofetch: + specifier: 'catalog:' + version: 1.4.1 p-map: specifier: 'catalog:' version: 7.0.3 @@ -396,12 +402,15 @@ importers: react-image: specifier: 'catalog:' version: 4.1.0(@babel/runtime@7.27.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-lazy-images: + react-intersection-observer: specifier: 'catalog:' - version: 1.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-modal: specifier: 'catalog:' version: 3.16.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + unionize: + specifier: 'catalog:' + version: 3.1.0 devDependencies: '@types/lodash.throttle': specifier: 'catalog:' @@ -4470,9 +4479,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5440,6 +5446,9 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + ofetch@1.4.1: + resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -6238,10 +6247,14 @@ packages: react: '>=16.8' react-dom: '>=16.8' - react-intersection-observer@6.4.2: - resolution: {integrity: sha512-gL6YrkhniA0tIbyDbUterzBwKh61vHR520rsKULel5T37gG4YP07wnWI3WoqOcKK5bKAu0PZB2FHD7/OjawN+w==} + react-intersection-observer@9.16.0: + resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==} peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6252,12 +6265,6 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-lazy-images@1.1.0: - resolution: {integrity: sha512-h5DHFhkMJyh2qsDl3hXWu6d+On10FsgHtRJ+BH7xjgsFOvsqaii9CEwEESqPJrrAiHo1qrN1LgzrV8X3zctHKA==} - peerDependencies: - react: ^15 || ^16 - react-dom: ^15 || ^16 - react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -7230,8 +7237,8 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - unionize@2.2.0: - resolution: {integrity: sha512-lHXiL6LPVuRYBGCLOdUd4GMHoAGqM0HtYHAZcA6pUEiwN1nk+LEYlh8bud7saeL0bkFntJzCPEPVVJeFm3Cqsg==} + unionize@3.1.0: + resolution: {integrity: sha512-LQZvbanzvpzvK0QYgRvnaA9VVe+g4nTbRlyoGGWDKFrZn0HJnDN5LC2Ti0+BxpKKOCrL3BQcRBVFPy/t12Y24Q==} unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} @@ -12349,10 +12356,6 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - invariant@2.2.4: - dependencies: - loose-envify: 1.4.0 - ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -13524,6 +13527,12 @@ snapshots: obuf@1.1.2: {} + ofetch@1.4.1: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.6 + ufo: 1.6.1 + ohash@2.0.11: {} on-finished@2.4.1: @@ -14334,11 +14343,11 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-intersection-observer@6.4.2(react@19.1.0): + react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@babel/runtime': 7.26.10 - invariant: 2.2.4 react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) react-is@16.13.1: {} @@ -14346,13 +14355,6 @@ snapshots: react-is@18.3.1: {} - react-lazy-images@1.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-intersection-observer: 6.4.2(react@19.1.0) - unionize: 2.2.0 - react-lifecycles-compat@3.0.4: {} react-modal@3.16.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -15572,7 +15574,7 @@ snapshots: unicorn-magic@0.3.0: {} - unionize@2.2.0: {} + unionize@3.1.0: {} unique-string@2.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05544bd2..f29a5dd6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,6 +33,7 @@ catalog: next: ^15.3.3 normalize-url: ^8.0.1 npm-run-all2: ^8.0.4 + ofetch: ^1.4.1 p-map: ^7.0.3 p-memoize: ^7.1.1 p-queue: ^8.1.0 @@ -43,7 +44,7 @@ catalog: react-fast-compare: ^3.2.0 react-hotkeys-hook: ^4.5.1 react-image: ^4.0.3 - react-lazy-images: ^1.1.0 + react-intersection-observer: ^9.16.0 react-modal: ^3.16.3 react-scripts: 5.0.1 react-tweet-embed: ^2.0.0 @@ -52,4 +53,5 @@ catalog: tsx: ^4.19.4 turbo: ^2.5.4 typescript: ^5.8.3 + unionize: ^3.1.0 vitest: ^3.2.2