Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 78 additions & 61 deletions packages/teamplay/react/wrapIntoSuspense.js
Original file line number Diff line number Diff line change
@@ -1,70 +1,82 @@
// useSyncExternalStore is used to trigger an update same as in MobX
// ref: https://github.com/mobxjs/mobx/blob/94bc4997c14152ff5aefcaac64d982d5c21ba51a/packages/mobx-react-lite/src/useObserver.ts
import { useSyncExternalStore, forwardRef as _forwardRef, memo, createElement as el, Suspense, useId, useRef } from 'react'
import { pipeComponentMeta, pipeComponentDisplayName, ComponentMetaContext } from './helpers.js'
import {
useSyncExternalStore,
forwardRef as _forwardRef,
memo,
createElement as el,
Suspense,
useId,
useRef,
} from "react";
import {
pipeComponentMeta,
pipeComponentDisplayName,
ComponentMetaContext,
} from "./helpers.js";

// TODO: probably add FinalizationRegistry to handle destruction of observer() before it ever mounted.
// In such case we might have a memory leak because subscribe() would never fire and would never
// clean up the cache
function destroyAdm (adm) {
adm.onStoreChange = undefined
adm.scheduledUpdatePromise = undefined
adm.scheduleUpdate = undefined
adm.cache?.clear()
adm.cache = undefined
function destroyAdm(adm) {
adm.onStoreChange = undefined;
adm.scheduledUpdatePromise = undefined;
adm.scheduleUpdate = undefined;
adm.cache?.clear();
adm.cache = undefined;
}

export default function wrapIntoSuspense ({
export default function wrapIntoSuspense({
Component,
forwardRef,
defer,
suspenseProps = DEFAULT_SUSPENSE_PROPS
suspenseProps = DEFAULT_SUSPENSE_PROPS,
} = {}) {
if (!suspenseProps?.fallback) throw Error(ERRORS.noFallback)
if (!suspenseProps?.fallback) throw Error(ERRORS.noFallback);

let SuspenseWrapper = (props, ref) => {
const componentId = useId()
const componentMetaRef = useRef()
const admRef = useRef()
const componentId = useId();
const componentMetaRef = useRef();
const admRef = useRef();
if (!admRef.current) {
const adm = {
stateVersion: Symbol(), // eslint-disable-line symbol-description
onStoreChange: undefined,
scheduledUpdatePromise: undefined,
hasPendingUpdate: false,
cache: new Map(),
scheduleUpdate: promise => {
if (!promise?.then) throw Error('scheduleUpdate() expects a promise')
if (adm.scheduledUpdatePromise === promise) return
adm.scheduledUpdatePromise = promise
scheduleUpdate: (promise) => {
if (!promise?.then) throw Error("scheduleUpdate() expects a promise");
if (adm.scheduledUpdatePromise === promise) return;
adm.scheduledUpdatePromise = promise;
promise.then(() => {
if (adm.scheduledUpdatePromise !== promise) return
adm.scheduledUpdatePromise = undefined
adm.onStoreChange?.()
})
if (adm.scheduledUpdatePromise !== promise) return;
adm.scheduledUpdatePromise = undefined;
adm.onStoreChange?.();
});
},
subscribe (onStoreChange) {
subscribe(onStoreChange) {
adm.onStoreChange = () => {
adm.stateVersion = Symbol() // eslint-disable-line symbol-description
onStoreChange()
}
adm.stateVersion = Symbol(); // eslint-disable-line symbol-description
onStoreChange();
};
// If there was a pending update before subscribe was called, trigger it asynchronously
// to avoid updating during the subscribe/render phase
if (adm.hasPendingUpdate) {
adm.hasPendingUpdate = false
queueMicrotask(() => adm.onStoreChange())
adm.hasPendingUpdate = false;
queueMicrotask(() => adm.onStoreChange?.());
}
return () => destroyAdm(adm)
return () => destroyAdm(adm);
},
getSnapshot() {
return adm.stateVersion;
},
getSnapshot () {
return adm.stateVersion
}
}
admRef.current = adm
};
admRef.current = adm;
}
const adm = admRef.current
const adm = admRef.current;

useSyncExternalStore(adm.subscribe, adm.getSnapshot, adm.getSnapshot)
useSyncExternalStore(adm.subscribe, adm.getSnapshot, adm.getSnapshot);

if (!componentMetaRef.current) {
componentMetaRef.current = {
Expand All @@ -73,47 +85,52 @@ export default function wrapIntoSuspense ({
defer,
triggerUpdate: () => {
if (adm.onStoreChange) {
adm.onStoreChange()
adm.onStoreChange();
} else {
// Save pending update - subscribe not called yet (e.g., from useEffect/useLayoutEffect)
adm.hasPendingUpdate = true
adm.hasPendingUpdate = true;
}
},
scheduleUpdate: promise => adm.scheduleUpdate?.(promise),
scheduleUpdate: (promise) => adm.scheduleUpdate?.(promise),
cache: {
get: key => adm.cache?.get(key),
get: (key) => adm.cache?.get(key),
set: (key, value) => adm.cache?.set(key, value),
has: key => adm.cache?.has(key)
}
}
has: (key) => adm.cache?.has(key),
},
};
}

if (forwardRef) props = { ...props, ref }
if (forwardRef) props = { ...props, ref };

return (
el(ComponentMetaContext.Provider, { value: componentMetaRef.current },
el(Suspense, suspenseProps,
el(Component, props)
)
)
)
}
return el(
ComponentMetaContext.Provider,
{ value: componentMetaRef.current },
el(Suspense, suspenseProps, el(Component, props)),
);
};

// pipe only displayName because forwardRef render function
// do not support propTypes or defaultProps
pipeComponentDisplayName(Component, SuspenseWrapper, 'StartupjsObserverWrapper')
pipeComponentDisplayName(
Component,
SuspenseWrapper,
"StartupjsObserverWrapper",
);

if (forwardRef) SuspenseWrapper = _forwardRef(SuspenseWrapper)
SuspenseWrapper = memo(SuspenseWrapper)
if (forwardRef) SuspenseWrapper = _forwardRef(SuspenseWrapper);
SuspenseWrapper = memo(SuspenseWrapper);

pipeComponentMeta(Component, SuspenseWrapper)
pipeComponentMeta(Component, SuspenseWrapper);

return SuspenseWrapper
return SuspenseWrapper;
}

const DEFAULT_SUSPENSE_PROPS = { fallback: el(NullComponent, null, null) }
function NullComponent () { return null }
const DEFAULT_SUSPENSE_PROPS = { fallback: el(NullComponent, null, null) };
function NullComponent() {
return null;
}

const ERRORS = {
noFallback: '[observer()] You must pass at least a fallback parameter to suspenseProps'
}
noFallback:
"[observer()] You must pass at least a fallback parameter to suspenseProps",
};