Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | 1x 280x 280x 55x 280x 280x 53x 53x 53x 650x 280x 280x 22x 280x 280x 280x 280x 74x 12x 8x 8x 2x 6x 6x 6x 280x 74x 280x 280x 54x 49x 48x 48x 280x 16x 16x 16x 16x 280x 280x 5x 4x 1x 1x 4x 1x 12x 5x 10x 4x | "use client";
/**
* React binding for the tree model (SPEC.md §4.9).
*
* The model is a **mutable external store** — that is the entire point, since
* copying the expansion state into React state on every toggle would undo the
* memory win. So it is subscribed to with `useSyncExternalStore` over the
* model's own version counter: a mutation re-renders without anything being
* cloned, and a concurrent render can never read half of a toggle.
*/
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { useControllableState } from "@usefy/use-controllable-state";
import { useEventCallback } from "@usefy/use-event-callback";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
import { createJsonTree } from "./model/tree";
import type { ExpandResult, JsonPath, JsonTreeModel, JsonTreeOptions } from "./types";
export interface UseJsonTreeOptions extends JsonTreeOptions {
/** The value to render. Held by reference — never cloned, never mutated. */
data: unknown;
/** Controlled expansion state, as RFC 6901 pointers. */
expanded?: readonly string[];
defaultExpanded?: readonly string[];
onExpandedChange?: (paths: string[]) => void;
}
export interface UseJsonTreeReturn {
model: JsonTreeModel;
rowCount: number;
toggle: (path: JsonPath) => ExpandResult;
expand: (path: JsonPath) => ExpandResult;
collapse: (path: JsonPath) => ExpandResult;
expandTo: (path: JsonPath) => ExpandResult;
expandAll: (maxDepth?: number) => ExpandResult;
collapseAll: () => ExpandResult;
}
/**
* Build a tree model over `data` and keep React in step with it.
*
* @example
* ```tsx
* const { model, rowCount, toggle } = useJsonTree({ data, defaultExpandDepth: 2 });
* const row = model.rowAt(0);
* ```
*/
export function useJsonTree(options: UseJsonTreeOptions): UseJsonTreeReturn {
const {
data,
expanded,
defaultExpanded,
onExpandedChange,
maxValueLength,
sortKeys,
defaultExpandDepth,
maxExpandedRows,
denseThreshold,
} = options;
const model = useMemo(
() =>
createJsonTree(data, {
maxValueLength,
sortKeys,
defaultExpandDepth,
maxExpandedRows,
denseThreshold,
}),
[data, maxValueLength, sortKeys, defaultExpandDepth, maxExpandedRows, denseThreshold],
);
const listeners = useRef(new Set<() => void>());
const subscribe = useCallback((listener: () => void) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
}, []);
const getVersion = useCallback(() => model.version(), [model]);
const version = useSyncExternalStore(subscribe, getVersion, getVersion);
const notify = useCallback(() => {
for (const listener of listeners.current) listener();
}, []);
/**
* Reading the expanded set means building one string per expanded node, so
* it is only done when somebody is listening. After `expandAll` on a large
* document that list is six figures long — paying for it on every toggle
* when nobody asked for it would be a self-inflicted performance bug.
*/
const tracksExpansion = expanded !== undefined || onExpandedChange !== undefined;
const [expandedPaths, setExpandedPaths] = useControllableState<readonly string[]>({
value: expanded,
defaultValue: defaultExpanded ?? [],
onChange: onExpandedChange as ((next: readonly string[]) => void) | undefined,
});
// Push a controlled `expanded` prop into the model. Compared as a set, not
// by identity: a parent that rebuilds the array every render must not make
// this loop.
/**
* The last prop set pushed into the model, and the model version it produced.
*
* Needed because the model cannot always *represent* the prop: a pointer that
* no longer resolves (the data changed, or the consumer persisted a path that
* has since gone) is dropped, so `getExpandedPaths()` legitimately comes back
* shorter than `expanded`. Re-pushing on that mismatch is an infinite loop —
* push, compare, differ, push. This records that the prop has already been
* applied to *this* version, so the next attempt only happens once the model
* has actually moved on.
*/
const pushed = useRef<{ key: string; version: number } | null>(null);
const applyControlled = useEventCallback(() => {
if (expanded === undefined) return;
if (sameSet(model.getExpandedPaths(), expanded)) return;
const key = [...expanded].sort().join(" ");
if (pushed.current?.key === key && pushed.current.version === model.version()) {
return;
}
model.setExpandedPaths(expanded);
pushed.current = { key, version: model.version() };
notify();
});
// `version` is a dependency on purpose. Without it, a parent that receives
// `onExpandedChange` and declines to echo it back never re-renders, no
// dependency changes, this effect never runs again — and the model keeps a
// change its owner rejected. That is a controlled component quietly behaving
// as an uncontrolled one, which is the one thing it must never do.
useIsomorphicLayoutEffect(() => {
applyControlled();
}, [applyControlled, expanded, model, version]);
// Seed an uncontrolled `defaultExpanded` once per model.
const seeded = useRef<JsonTreeModel | null>(null);
useIsomorphicLayoutEffect(() => {
if (expanded !== undefined) return;
if (seeded.current === model) return;
seeded.current = model;
Iif (defaultExpanded && defaultExpanded.length > 0) {
model.setExpandedPaths(defaultExpanded);
notify();
}
}, [model, expanded, defaultExpanded, notify]);
const run = useCallback(
(operation: () => ExpandResult): ExpandResult => {
const result = operation();
Eif (result.delta !== 0 || result.refused) notify();
if (tracksExpansion) setExpandedPaths(model.getExpandedPaths());
return result;
},
[model, notify, setExpandedPaths, tracksExpansion],
);
// `expandedPaths` is deliberately not read here: `useControllableState` owns
// it so a controlled consumer gets change notifications, while the model
// stays the single source of truth for rendering.
void expandedPaths;
return {
model,
rowCount: model.rowCount(),
toggle: useCallback((path) => run(() => model.toggle(path)), [model, run]),
expand: useCallback((path) => run(() => model.expand(path)), [model, run]),
collapse: useCallback((path) => run(() => model.collapse(path)), [model, run]),
expandTo: useCallback((path) => run(() => model.expandTo(path)), [model, run]),
expandAll: useCallback(
(maxDepth?: number) => run(() => model.expandAll(maxDepth)),
[model, run],
),
collapseAll: useCallback(() => run(() => model.collapseAll()), [model, run]),
};
}
function sameSet(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
const set = new Set(a);
for (const item of b) if (!set.has(item)) return false;
return true;
}
|