object-state.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
  2. import { Platform } from 'react-native';
  3. /**
  4. * Latest known state of social objects (likes, reactions, comments, reports, ...)
  5. * and of comment previews, shared by every widget that shows the same object.
  6. *
  7. * A widget used to keep the result of its own action or of a socket event in
  8. * local state, so the change was lost when the widget unmounted: lists keep
  9. * their data in caches (TanStack, Next router, native page cache), and a list
  10. * mounted again from a cache showed the object as it was when the list loaded.
  11. * Here the change outlives the widget, and every copy of the object (the same
  12. * post in two feeds, the action and the counter halves of one menu) shows it.
  13. *
  14. * Which is newer, the props or the shared state, is decided by `ts`: the server
  15. * time (seconds, float) UNA puts in object data, action responses and socket
  16. * events. Props win only when their `ts` is newer; without `ts` (an older UNA)
  17. * the shared state wins. Values from UNA are absolute, never local increments,
  18. * so the next action or event always brings an object up to date.
  19. *
  20. * Next server: one module instance serves every visitor, so nothing is stored
  21. * there and server renders read props only.
  22. */
  23. type Entry = {
  24. /** Object data from a reload, shaped like the widget props. */
  25. props?: any;
  26. /** Widget state after an action or a socket event (wins over `props`). */
  27. state?: any;
  28. /** Server time of the data. */
  29. ts?: number;
  30. };
  31. const MAX_ENTRIES = 500;
  32. const entries = new Map<string, Entry>();
  33. const listeners = new Map<string, Set<() => void>>();
  34. const isServer = Platform.OS === 'web' && typeof window === 'undefined';
  35. /** Server time of object data (`ts` from UNA), or undefined. */
  36. export function objectTs(data: any): number | undefined {
  37. const ts = Number(data?.ts);
  38. return Number.isFinite(ts) && ts > 0 ? ts : undefined;
  39. }
  40. /** True when `ts` is known to be newer than the entry. */
  41. function isNewer(ts: number | undefined, entry: Entry | undefined) {
  42. return ts !== undefined && entry?.ts !== undefined && ts > entry.ts;
  43. }
  44. function notify(key: string) {
  45. listeners.get(key)?.forEach((listener) => listener());
  46. }
  47. function put(key: string, entry: Entry) {
  48. if (isServer || !key) return;
  49. // Re-insert so Map order is least recently written first.
  50. entries.delete(key);
  51. entries.set(key, entry);
  52. // Evict the oldest entries no mounted widget reads: an evicted entry would
  53. // send a mounted widget back to its (older) props. The limit gives way
  54. // when every entry is on screen.
  55. if (entries.size > MAX_ENTRIES) {
  56. for (const oldest of [...entries.keys()]) {
  57. if (entries.size <= MAX_ENTRIES) break;
  58. if (oldest !== key && !listeners.has(oldest)) entries.delete(oldest);
  59. }
  60. }
  61. notify(key);
  62. }
  63. function subscribeKey(key: string, listener: () => void) {
  64. let set = listeners.get(key);
  65. if (!set) {
  66. set = new Set();
  67. listeners.set(key, set);
  68. }
  69. set.add(listener);
  70. return () => {
  71. set.delete(listener);
  72. if (set.size === 0) listeners.delete(key);
  73. };
  74. }
  75. /** Key of a social object: `type-system-objectId`, as `objectKey` in molecules/objects. */
  76. export function socialObjectKey(type: string, system: string, objectId: string | number) {
  77. return [type, String(system).replace(/_/g, '-'), objectId].join('-');
  78. }
  79. /** Key of a comments preview (`cmts` of a timeline item). */
  80. export function commentsObjectKey(module: string, objectId: string | number) {
  81. return 'cmts-' + module + '-' + objectId;
  82. }
  83. /** Key of a node in an API response, or null when it isn't an object we track. */
  84. function nodeKey(node: any): string | null {
  85. if (node.object_id == null || node.object_id === '') return null;
  86. if (node.unit === 'comments' && typeof node.module === 'string') {
  87. return commentsObjectKey(node.module, node.object_id);
  88. }
  89. if (typeof node.type === 'string' && typeof node.system === 'string' && node.system) {
  90. return socialObjectKey(node.type, node.system, node.object_id);
  91. }
  92. return null;
  93. }
  94. /**
  95. * Share the objects of a response that was loaded because it changed (a feed
  96. * item reloaded after a socket `edited`), so the change outlives the card.
  97. */
  98. export function storeObjectsFrom(data: unknown) {
  99. if (isServer) return;
  100. const stack: unknown[] = [data];
  101. while (stack.length) {
  102. const node = stack.pop();
  103. if (!node || typeof node !== 'object') continue;
  104. if (Array.isArray(node)) {
  105. for (const child of node) stack.push(child);
  106. continue;
  107. }
  108. const key = nodeKey(node);
  109. if (key) {
  110. const ts = objectTs(node);
  111. const entry = entries.get(key);
  112. if (!entry || isNewer(ts, entry) || ts === undefined) put(key, { props: node, ts });
  113. }
  114. for (const name in node) {
  115. const child = (node as Record<string, unknown>)[name];
  116. if (child && typeof child === 'object') stack.push(child);
  117. }
  118. }
  119. }
  120. let generation = 0;
  121. /**
  122. * Changes on every clear. A request started before a logout or a profile
  123. * switch compares it before writing, so the previous viewer's state can't
  124. * come back.
  125. */
  126. export function objectStateGeneration() {
  127. return generation;
  128. }
  129. /** Drop everything (logout, profile switch). */
  130. export function clearObjectState() {
  131. generation++;
  132. const keys = [...entries.keys()];
  133. entries.clear();
  134. keys.forEach(notify);
  135. }
  136. const noopUnsubscribe = () => {};
  137. const emptySnapshot = (): Entry | undefined => undefined;
  138. type SetState<T> = (next: T | ((prev: T) => T)) => void;
  139. /**
  140. * `useState` for a widget's server-side state, shared under `key`. `init` builds
  141. * the state from props and should keep their `ts`; responses merged into the
  142. * state bring their own `ts`. Without a key the state stays in the component.
  143. */
  144. export function useObjectState<T>(key: string | null | undefined, props: any, init: (props: any) => T): [T, SetState<T>] {
  145. const shared = !!key && !isServer;
  146. const subscribe = useCallback(
  147. (listener: () => void) => (shared ? subscribeKey(key as string, listener) : noopUnsubscribe),
  148. [shared, key]
  149. );
  150. const getSnapshot = useCallback(() => (shared ? entries.get(key as string) : undefined), [shared, key]);
  151. const entry = useSyncExternalStore(subscribe, getSnapshot, emptySnapshot);
  152. const [local, setLocal] = useState<{ value: T } | null>(null);
  153. const propsTs = objectTs(props);
  154. const propsNewer = isNewer(propsTs, entry);
  155. const current = propsNewer ? undefined : entry;
  156. let value: T;
  157. if (!shared && local) value = local.value;
  158. else if (current?.state !== undefined) value = current.state as T;
  159. else value = init(current?.props ?? props);
  160. // Share props that are the newest data for the object: the first copy on
  161. // screen seeds the entry, a reloaded list replaces an older one. Older copies
  162. // then show them too, and a socket update merges onto them rather than onto
  163. // an older copy's fields. Props without `ts` (an older UNA) never seed:
  164. // nothing would let a fresher copy replace them. Runs again when the entry
  165. // changes, so a cleared or evicted entry is seeded by the copies on screen.
  166. useEffect(() => {
  167. if (!shared || propsTs === undefined) return;
  168. const stored = entries.get(key as string);
  169. if (!stored || isNewer(propsTs, stored)) put(key as string, { props, ts: propsTs });
  170. }, [shared, key, props, propsTs, entry]);
  171. const setValue = useCallback<SetState<T>>(
  172. (next) => {
  173. const apply = (prev: T) => (typeof next === 'function' ? (next as (prev: T) => T)(prev) : next);
  174. if (shared) {
  175. // Read the store, not the render: socket callbacks are bound once on mount.
  176. const stored = entries.get(key as string);
  177. const base = isNewer(propsTs, stored) ? undefined : stored;
  178. const prev = base?.state !== undefined ? (base.state as T) : init(base?.props ?? props);
  179. const state = apply(prev);
  180. put(key as string, { state, ts: objectTs(state) ?? base?.ts ?? propsTs });
  181. } else {
  182. setLocal((prev) => ({ value: apply(prev ? prev.value : init(props)) }));
  183. }
  184. },
  185. [shared, key, init, props, propsTs]
  186. );
  187. return [value, setValue];
  188. }