@hueest/xray 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import { r as Plate } from "./plate-BIr62u7l.js";
2
+ import { a as XrayCaptureWalker } from "./serialize-Cs7hUxmK.js";
3
+ import { ReactNode } from "react";
4
+
5
+ //#region src/react.core.d.ts
6
+ /**
7
+ * The additive render-prop children form (ADR 0014). Called only at
8
+ * `loading=false` (null included — `loading` stays the sole readiness axis, ADR
9
+ * 0002), it hands back the typed `data` verbatim plus a `ref` for the manual
10
+ * capture root.
11
+ *
12
+ * `ref` is a CALLBACK ref by contract: in `root="manual"` (dev) it imperatively
13
+ * writes the `data-xr-boundary` marker onto the consumer's own element and
14
+ * registers it as the capture root, so it must run on the real DOM node. In
15
+ * `root="auto"` and in production it is a no-op (omitted), and the existing
16
+ * `display:contents` boundary wrapper does the marking instead.
17
+ *
18
+ * It is typed as a callback ref over the base `Element` rather than a `Ref<T>`.
19
+ * A callback that accepts `Element | null` is, by parameter contravariance,
20
+ * assignable to a more specific `ref` slot (`<section ref={ref}>` wants
21
+ * `(el: HTMLElement | null) => void`), so the consumer spreads it onto any host
22
+ * element without a cast. A `RefObject` would NOT compose this way and is not
23
+ * offered: writing the boundary attribute needs the imperative callback anyway.
24
+ */
25
+ type SkeletonRef = (el: Element | null) => void;
26
+ type SkeletonRenderProp<T> = (ctx: {
27
+ data: T;
28
+ ref?: SkeletonRef;
29
+ }) => ReactNode;
30
+ /** Fields shared by both `root` variants of `<Skeleton>`. */
31
+ interface SkeletonBase {
32
+ /** The plate to render while loading, imported from `virtual:xray/plates/<name>`. */
33
+ plate: Plate;
34
+ /** Explicit readiness mode: render the Skeleton while true, then render children and capture them in dev when false. */
35
+ loading?: boolean;
36
+ /** Suspense mode: make `<Skeleton>` the Suspense boundary and use the Plate as the fallback until children resolve. */
37
+ suspense?: boolean;
38
+ /** Shown while loading when the plate has no capture yet. */
39
+ fallback?: ReactNode;
40
+ /** Override the plugin's settle delay before this site is captured (dev only). */
41
+ delay?: number;
42
+ /**
43
+ * Programmable capture walker for this Skeleton's subtree (ADR 0018, dev only).
44
+ * The escape hatch beyond the declarative `data-xr-*` annotations: a closure
45
+ * that decides, per element, whether to ignore it, collapse it to one Bone, or
46
+ * defer to the built-in walk. Threaded through the dev adapter into
47
+ * `captureView`; PRODUCTION ignores it entirely (it never captures). On both
48
+ * `root` variants of the union, so a manual root may carry one too.
49
+ */
50
+ captureWalker?: XrayCaptureWalker;
51
+ }
52
+ /**
53
+ * Props accepted by the React `<Skeleton>` component, generic over the optional
54
+ * render-prop `data` payload `T` (ADR 0014). The generic defaults to `undefined`
55
+ * so existing call sites — `<Skeleton plate loading />`, plain children — keep
56
+ * working with zero type arguments; passing `data` infers `T` into the render
57
+ * prop. `data` is a pure pass-through (the consumer owns nullability), never
58
+ * `NonNullable<T>`.
59
+ *
60
+ * The two members discriminate on `root`:
61
+ * - `root="auto"` (default): the existing `display:contents` boundary wrapper
62
+ * marks the capture seam (ADR 0006); children may be plain `ReactNode` OR a
63
+ * render prop, and the surfaced `ref` is a no-op.
64
+ * - `root="manual"`: no wrapper; the consumer owns the capture root element. This
65
+ * REQUIRES render-prop children (the only way to hand back the `ref` that
66
+ * writes the boundary marker), so plain children + `root="manual"` is a type
67
+ * error.
68
+ */
69
+ type SkeletonProps<T = undefined> = SkeletonBase & ({
70
+ /** Auto capture root (default): xray inserts a `display:contents` boundary wrapper. */root?: 'auto'; /** Render-prop payload, passed back to the children function verbatim as `T`. */
71
+ data?: T; /** Real content. In dev, these children are also the capture source once ready. Either plain `ReactNode` or a render prop. */
72
+ children?: ReactNode | SkeletonRenderProp<T>;
73
+ } | {
74
+ /** Manual capture root: the consumer's element owns the boundary marker; no wrapper (ADR 0014). */root: 'manual'; /** Render-prop payload, passed back to the children function verbatim as `T`. */
75
+ data?: T; /** Render prop required: spread the supplied `ref` onto your root element so capture does not leak into the parent plate. */
76
+ children: SkeletonRenderProp<T>;
77
+ });
78
+ //#endregion
79
+ export { SkeletonProps as t };
@@ -0,0 +1,272 @@
1
+ import { a as ROOT_ATTR, o as ROOT_CLASS, s as ROOT_INSTANT_ATTR, t as BASE_CSS } from "./plate-BRR6d8Se.js";
2
+ import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
3
+ import { Fragment, useEffect, useRef, useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/react.core.tsx
6
+ /**
7
+ * The live-stitch registry (ADR 0020): a module-level ref-counted set of stitch
8
+ * names that are CURRENTLY rendered as a stitch inside a still-SHOWING parent
9
+ * skeleton. It exists to kill the "stitch flash": when a parent skeleton resolves
10
+ * and its real content mounts a descendant's OWN standalone `<Skeleton>` for the
11
+ * same plate, that standalone is a NEW outermost skeleton — it would re-pay the
12
+ * full `--xr-skeleton-delay` (ADR 0016) from zero and visually disappear for
13
+ * ~250ms even though the child was continuously visible as a stitch. A standalone
14
+ * skeleton replacing such a live stitch must reveal INSTANTLY (no delay, no fade),
15
+ * matching how the stitch inherited its parent's already-revealed channel.
16
+ *
17
+ * Why a loading-keyed registry and not wall-clock time: the parent marks its
18
+ * direct stitch names live in an effect WHILE it shows its skeleton (an earlier
19
+ * commit). When the parent resolves, React renders the content tree — the child's
20
+ * standalone Skeleton first-renders and reads `isStitchLive(child)` BEFORE the
21
+ * parent's unmark cleanup runs (render happens before that commit's effects), so
22
+ * the read deterministically sees `child` still live. The unmark then clears it.
23
+ * "Recently" is defined by React's render-before-effect ordering, not a timer, so
24
+ * it is race-free AND does not over-apply to independent nested skeletons (a
25
+ * tab-hidden skeleton was never marked live by a currently-showing parent → not
26
+ * live → normal delay). It is portal-correct for free: only genuine stitches
27
+ * (names in a parent plate's stitch set) are ever registered, so a portaled child
28
+ * is never a stitch, never live, and reveals on the normal delay (ADR 0020).
29
+ *
30
+ * This ships to PROD: the flash is a production issue too, and the cost is a Map
31
+ * plus a register effect and a read-once `useState` per `<Skeleton>` — a few
32
+ * bytes, consistent with the prod-hooks relaxation ADR 0016 opened.
33
+ */
34
+ const liveStitches = /* @__PURE__ */ new Map();
35
+ /**
36
+ * Mark a parent skeleton's direct stitch names live (ref-counted, so concurrent
37
+ * showing parents that stitch the same child stack), returning a disposer that
38
+ * decrements each. The adapter calls this in an effect while `showSkeleton` is
39
+ * true and runs the disposer when it stops showing (resolve/unmount). Several
40
+ * marks of the same name increment the count; the count drops to 0 (entry
41
+ * removed) only when every showing parent has unmarked it (ADR 0020).
42
+ */
43
+ function markStitchesLive(names) {
44
+ for (const name of names) liveStitches.set(name, (liveStitches.get(name) ?? 0) + 1);
45
+ return () => {
46
+ queueMicrotask(() => {
47
+ for (const name of names) {
48
+ const next = (liveStitches.get(name) ?? 0) - 1;
49
+ if (next <= 0) liveStitches.delete(name);
50
+ else liveStitches.set(name, next);
51
+ }
52
+ });
53
+ };
54
+ }
55
+ /** True while `name` is rendered as a stitch inside a still-showing parent
56
+ * skeleton — i.e. a standalone `<Skeleton>` for it is a continuation, not a fresh
57
+ * skeleton, and must reveal instantly (ADR 0020). */
58
+ function isStitchLive(name) {
59
+ return (liveStitches.get(name) ?? 0) > 0;
60
+ }
61
+ /**
62
+ * The direct stitch names a plate's chunks reference (ADR 0020): a `{ r: name }`
63
+ * chunk IS a stitch; a `{ c, k }` chunk is a stitch-bearing element kept real, so
64
+ * recurse its `k`. Walks the SHIPPED `chunks` (the render contract), not
65
+ * `plate.refs` (empty in dev, where the live store resolves stitches) nor
66
+ * `collectRefs` (which walks the build-time RenderNode tree, not the shipped
67
+ * chunks). One level of direct stitch names per Skeleton is enough: each level
68
+ * self-registers as it becomes a real `<Skeleton>` (the cascade), so arbitrary
69
+ * nesting is covered without recursing into child plates here.
70
+ */
71
+ function collectStitchNames(chunks) {
72
+ if (!chunks) return [];
73
+ const names = [];
74
+ const walk = (cs) => {
75
+ for (const chunk of cs) {
76
+ if (typeof chunk === "string") continue;
77
+ if ("t" in chunk) continue;
78
+ if ("r" in chunk) names.push(chunk.r);
79
+ else walk(chunk.k);
80
+ }
81
+ };
82
+ walk(chunks);
83
+ return names;
84
+ }
85
+ /** Narrow unknown children to the render-prop form. */
86
+ function isRenderProp(children) {
87
+ return typeof children === "function";
88
+ }
89
+ /**
90
+ * Resolve `<Skeleton>` content at the ready (`loading=false`) path: invoke the
91
+ * render prop with the verbatim `data` and the adapter's `ref` (a no-op in prod
92
+ * and in `root="auto"`, the boundary-writing callback in dev `root="manual"`), or
93
+ * pass plain children through untouched. Centralizes the single `data as T` step:
94
+ * `data` is declared `data?: T`, so it reads back as `T | undefined`, but it is
95
+ * the exact value the consumer passed for the render prop's `T` (ADR 0014 keeps it
96
+ * a pass-through, not `NonNullable<T>`).
97
+ */
98
+ function resolveContent(children, data, ref) {
99
+ if (!isRenderProp(children)) return children;
100
+ return children({
101
+ data,
102
+ ref
103
+ });
104
+ }
105
+ /**
106
+ * The token defaults that mirror the BASE_CSS `var(--xr-skeleton-*, …)` fallbacks
107
+ * (ADR 0016). The hook reads the COMPUTED token off the boundary element, but a
108
+ * consumer who never sets it leaves the computed value empty (the tokens are not
109
+ * declared on `.xr-root`, only referenced with these inline fallbacks). So when a
110
+ * read comes back empty/unparseable the hook falls back to these same numbers,
111
+ * keeping the JS hold and the CSS reveal in lockstep on identical defaults.
112
+ */
113
+ const DEFAULT_SKELETON_DELAY_MS = 250;
114
+ const DEFAULT_SKELETON_MIN_DURATION_MS = 600;
115
+ /**
116
+ * Parse a CSS time token (`getPropertyValue` returns a trimmed string like
117
+ * `"250ms"`, `"0.6s"`, or `""`) to milliseconds, falling back to `fallback` when
118
+ * empty or unparseable. Supports `ms` and `s`; a bare number is treated as ms.
119
+ */
120
+ function parseMs(value, fallback) {
121
+ const text = value.trim();
122
+ if (text === "") return fallback;
123
+ const match = /^(-?[\d.]+)(ms|s)?$/.exec(text);
124
+ if (!match?.[1]) return fallback;
125
+ const n = Number.parseFloat(match[1]);
126
+ if (!Number.isFinite(n)) return fallback;
127
+ return match[2] === "s" ? n * 1e3 : n;
128
+ }
129
+ /**
130
+ * The min-duration hold (ADR 0016), shared by the production (`react.tsx`) and dev
131
+ * (`react.dev.tsx`) adapters so both keep identical timing. Returns whether the
132
+ * skeleton should render RIGHT NOW.
133
+ *
134
+ * The reveal DELAY is pure CSS (BASE_CSS `--xr-reveal`, zero JS); this hook is
135
+ * ONLY the part CSS cannot express: keeping the skeleton mounted past
136
+ * `loading=false` for a minimum visible window. The hook returns `loading ||
137
+ * holding`: the `loading` term is SYNCHRONOUS, so the skeleton shows on the very
138
+ * render `loading` flips true (no content frame at a false→true restart, and no
139
+ * stale-state lag), and `holding` is state used SOLELY for the post-ready hold.
140
+ *
141
+ * While loading, an effect anchors `t0` (loading start) and pre-arms `holding`
142
+ * true, so that pre-armed value carries into the first `loading=false` render with
143
+ * no content flash at the true→false transition. When `loading` flips false the
144
+ * effect reads the COMPUTED `--xr-skeleton-delay` + `--xr-skeleton-min-duration`
145
+ * off `ref` and holds until `t0 + delay + min-duration`. Two cases skip the hold
146
+ * entirely (swap synchronously, no timer): min-duration resolves to 0, or `ref` is
147
+ * null / no `t0` was recorded — meaning no skeleton root was ever shown (e.g. a
148
+ * plate with no capture, just a fallback), so there is nothing to hold and a hold
149
+ * would only keep blank/fallback UI on screen after data is ready.
150
+ *
151
+ * Reading computed custom props off the skeleton root (open Q4): the root carries
152
+ * `display:contents`, but custom-property inheritance and resolution are
153
+ * unaffected by `display` — `getComputedStyle(el).getPropertyValue('--xr-…')`
154
+ * returns the inherited token value on a `display:contents` element just as on any
155
+ * other. The tokens are not declared on `.xr-root` (only referenced with inline
156
+ * fallbacks), so a consumer who never themes them yields an empty read; `parseMs`
157
+ * then applies the same defaults the CSS uses, so the hold matches the reveal.
158
+ */
159
+ function useSkeletonTiming(loading, ref) {
160
+ const [holding, setHolding] = useState(loading);
161
+ const startRef = useRef(null);
162
+ const timerRef = useRef(void 0);
163
+ useEffect(() => {
164
+ const clear = () => {
165
+ if (timerRef.current !== void 0) {
166
+ clearTimeout(timerRef.current);
167
+ timerRef.current = void 0;
168
+ }
169
+ };
170
+ if (loading) {
171
+ clear();
172
+ if (startRef.current === null) startRef.current = Date.now();
173
+ setHolding(true);
174
+ return clear;
175
+ }
176
+ const start = startRef.current;
177
+ startRef.current = null;
178
+ const el = ref.current;
179
+ if (el === null || start === null) {
180
+ clear();
181
+ setHolding(false);
182
+ return clear;
183
+ }
184
+ const style = getComputedStyle(el);
185
+ const delay = parseMs(style.getPropertyValue("--xr-skeleton-delay"), DEFAULT_SKELETON_DELAY_MS);
186
+ const minDuration = parseMs(style.getPropertyValue("--xr-skeleton-min-duration"), DEFAULT_SKELETON_MIN_DURATION_MS);
187
+ if (minDuration <= 0) {
188
+ clear();
189
+ setHolding(false);
190
+ return clear;
191
+ }
192
+ const remaining = start + delay + minDuration - Date.now();
193
+ if (remaining <= 0) {
194
+ clear();
195
+ setHolding(false);
196
+ return clear;
197
+ }
198
+ clear();
199
+ timerRef.current = setTimeout(() => {
200
+ timerRef.current = void 0;
201
+ setHolding(false);
202
+ }, remaining);
203
+ return clear;
204
+ }, [loading, ref]);
205
+ return loading || holding;
206
+ }
207
+ /** Layout-neutral wrapper for an HTML island sitting beside React stitch nodes —
208
+ * its children participate in the grandparent's flow, so grid/flex item counts
209
+ * are unchanged. */
210
+ const DISPLAY_CONTENTS = { display: "contents" };
211
+ /**
212
+ * Render a shipped plate's skeleton. `resolveStitch` turns a child plate name
213
+ * into the node that mounts it (static `refs` in prod, a store-subscribed
214
+ * component in dev). `nested` is true for a stitch-mounted child, which skips
215
+ * BASE_CSS — the outermost view already emitted it (ADR 0007). Returns null
216
+ * when the plate has no capture yet; the caller renders the fallback.
217
+ *
218
+ * `instant` (ADR 0020) marks this root a stitch continuation: a standalone
219
+ * `<Skeleton>` replacing a stitch a still-showing parent was just rendering. It
220
+ * lands as `data-xr-instant` on the OUTERMOST root only (`instant && !nested`) so
221
+ * BASE_CSS reveals it without the `--xr-skeleton-delay` fade.
222
+ */
223
+ function renderPlate(plate, resolveStitch, nested, rootRef, instant) {
224
+ if (!plate.chunks) return null;
225
+ const className = plate.rootCls ? `${ROOT_CLASS} ${plate.rootCls}` : ROOT_CLASS;
226
+ const rootAttrs = {
227
+ [ROOT_ATTR]: plate.name,
228
+ className,
229
+ "aria-hidden": true,
230
+ "aria-busy": true,
231
+ ...rootRef ? { ref: rootRef } : {},
232
+ ...instant && !nested ? { [ROOT_INSTANT_ATTR]: "" } : {}
233
+ };
234
+ const css = nested ? plate.css : `${BASE_CSS}\n${plate.css}`;
235
+ const chunks = plate.chunks;
236
+ if (chunks.length === 1 && typeof chunks[0] === "string") {
237
+ const html = `<style>${escapeStyleText(css)}</style>${chunks[0]}`;
238
+ return /* @__PURE__ */ jsx("div", {
239
+ ...rootAttrs,
240
+ dangerouslySetInnerHTML: { __html: html }
241
+ });
242
+ }
243
+ return /* @__PURE__ */ jsxs("div", {
244
+ ...rootAttrs,
245
+ children: [/* @__PURE__ */ jsx("style", { children: css }), renderChunks(chunks, resolveStitch)]
246
+ });
247
+ }
248
+ /**
249
+ * Each `string` chunk is injected as a display:contents innerHTML island; each
250
+ * `{ r }` stitch resolves through `resolveStitch`; a `{ c, k }` node stays a
251
+ * real element and recurses (it holds a stitch deeper). No per-node tree walk —
252
+ * the strings are already serialized.
253
+ */
254
+ function renderChunks(chunks, resolveStitch) {
255
+ return chunks.map((chunk, i) => {
256
+ if (typeof chunk === "string") return /* @__PURE__ */ jsx("div", {
257
+ style: DISPLAY_CONTENTS,
258
+ dangerouslySetInnerHTML: { __html: chunk }
259
+ }, i);
260
+ if ("t" in chunk) return /* @__PURE__ */ jsx("div", {
261
+ style: DISPLAY_CONTENTS,
262
+ dangerouslySetInnerHTML: { __html: chunk.t.repeat(chunk.n) }
263
+ }, i);
264
+ if ("r" in chunk) return /* @__PURE__ */ jsx(Fragment, { children: resolveStitch(chunk.r) }, i);
265
+ return /* @__PURE__ */ jsx("div", {
266
+ className: chunk.c,
267
+ children: renderChunks(chunk.k, resolveStitch)
268
+ }, i);
269
+ });
270
+ }
271
+ //#endregion
272
+ export { renderPlate as a, markStitchesLive as i, isRenderProp as n, resolveContent as o, isStitchLive as r, useSkeletonTiming as s, collectStitchNames as t };
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SkeletonProps } from "./react.core-fgWG_svU.js";
1
+ import { t as SkeletonProps } from "./react.core-BUj8ziwh.js";
2
2
 
3
3
  //#region src/react.d.ts
4
4
  /**
@@ -18,12 +18,6 @@ import { t as SkeletonProps } from "./react.core-fgWG_svU.js";
18
18
  * In production this component ships no capture client, HUD, Light Box, store,
19
19
  * or subscriptions. Stitches resolve from the statically imported `refs` graph.
20
20
  */
21
- declare function Skeleton({
22
- plate,
23
- loading,
24
- suspense,
25
- fallback,
26
- children
27
- }: SkeletonProps): import("react").JSX.Element;
21
+ declare function Skeleton<T = undefined>(props: SkeletonProps<T>): import("react").JSX.Element;
28
22
  //#endregion
29
23
  export { Skeleton, type SkeletonProps };
@@ -1,4 +1,4 @@
1
- import { t as SkeletonProps } from "./react.core-fgWG_svU.js";
1
+ import { t as SkeletonProps } from "./react.core-BUj8ziwh.js";
2
2
 
3
3
  //#region src/react.dev.d.ts
4
4
  /**
@@ -7,13 +7,6 @@ import { t as SkeletonProps } from "./react.core-fgWG_svU.js";
7
7
  * `@hueest/xray/react` to this module in `serve`; production gets the hook-free
8
8
  * `react.tsx`.
9
9
  */
10
- declare function Skeleton({
11
- plate,
12
- loading,
13
- suspense,
14
- fallback,
15
- delay,
16
- children
17
- }: SkeletonProps): import("react").JSX.Element;
10
+ declare function Skeleton<T = undefined>(props: SkeletonProps<T>): import("react").JSX.Element;
18
11
  //#endregion
19
12
  export { Skeleton, type SkeletonProps };
package/dist/react.dev.js CHANGED
@@ -1,6 +1,6 @@
1
- import { n as BOUNDARY_ATTR } from "./plate-BuzRMPx4.js";
2
- import { t as renderPlate } from "./react.core-CqnDjfAJ.js";
3
- import { Suspense, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { n as BOUNDARY_ATTR } from "./plate-BRR6d8Se.js";
2
+ import { a as renderPlate, i as markStitchesLive, n as isRenderProp, o as resolveContent, r as isStitchLive, s as useSkeletonTiming, t as collectStitchNames } from "./react.core-DMIhXHZF.js";
3
+ import { Suspense, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
5
5
  //#region src/react.dev.tsx
6
6
  const noSubscribe = () => () => {};
@@ -10,6 +10,67 @@ function useLightBox() {
10
10
  const lightbox = globalThis.__XRAY__?.lightbox;
11
11
  return useSyncExternalStore(lightbox?.subscribe ?? noSubscribe, lightbox?.get ?? alwaysFalse, alwaysFalse);
12
12
  }
13
+ /** True while the HUD/console "Replay" toggle is on (ADR 0015). */
14
+ function useReplayOn() {
15
+ const replay = globalThis.__XRAY__?.replay;
16
+ return useSyncExternalStore(replay?.subscribe ?? noSubscribe, replay?.get ?? alwaysFalse, alwaysFalse);
17
+ }
18
+ /**
19
+ * The Fixtures (ADR 0015) seam, applied at the ready (`loading=false`) path right
20
+ * before `resolveContent`. It is the ONE place Record reads `data` and Replay
21
+ * substitutes it — the render-prop `data` argument is in scope here (open Q1,
22
+ * confirmed).
23
+ *
24
+ * Replay: when the toggle is on and a Fixture exists, the recorded `data` is
25
+ * substituted for the live `data` according to `replayMode` — `'prefer'` always
26
+ * uses the Fixture; `'missing-only'` uses it only when live `data` is NULLISH
27
+ * (`missing ≡ nullish`, so an empty array/object is present and stays
28
+ * capturable). The substituted value is returned synchronously so it feeds
29
+ * `resolveContent` for THIS render; the capture-all sweep only re-renders at each
30
+ * width and reads the SAME Fixture from the store (which the sweep never changes
31
+ * per width), so every View is captured from one width-independent Fixture with
32
+ * no new capture trigger (open Q2, confirmed — capture follows whatever renders,
33
+ * ADR 0002).
34
+ *
35
+ * Record: the live `data` is registered (in an effect, so render stays pure) so
36
+ * the HUD button and the auto modes always have the current input in scope. The
37
+ * auto modes (`'first-ready'`, `'always'`) fire from the same effect; `'manual'`
38
+ * (the default) records nothing automatically. We register and auto-record the
39
+ * LIVE `data`, never the replayed value, so Replay and Record never feed each
40
+ * other.
41
+ *
42
+ * Only the render-prop children form is the `data` seam (ADR 0014/0015), so
43
+ * Fixtures apply to it alone: `renderProp` gates participation. A plain-children
44
+ * Skeleton has no `data` input, so it must NOT register, auto-record, or replay —
45
+ * the hook is a pass-through for it, which keeps a never-registered plain name out
46
+ * of `recordAll` and lets `record(name)` throw loudly (no render-prop data) as the
47
+ * store contract documents. A render-prop Skeleton passed `data={undefined}` is
48
+ * still a render prop and participates exactly as any other.
49
+ */
50
+ function useFixtureData(name, data, renderProp) {
51
+ const replayOn = useReplayOn();
52
+ const fixturesStore = globalThis.__XRAY__?.fixtures;
53
+ const recordedFixture = useSyncExternalStore(useCallback((onChange) => fixturesStore?.subscribe(onChange) ?? noSubscribe(), [fixturesStore]), () => fixturesStore?.read(name), () => void 0);
54
+ useEffect(() => {
55
+ if (!renderProp) return void 0;
56
+ const fx = globalThis.__XRAY__?.fixtures;
57
+ if (!fx) return void 0;
58
+ const dispose = fx.register(name, data);
59
+ const mode = globalThis.__XRAY__?.recordMode ?? "manual";
60
+ if (mode === "always" || mode === "first-ready" && !fx.has(name)) fx.record(name).catch((error) => {
61
+ console.error(`[xray] fixture record failed for "${name}":`, error);
62
+ });
63
+ return dispose;
64
+ }, [
65
+ name,
66
+ data,
67
+ renderProp
68
+ ]);
69
+ if (!renderProp) return data;
70
+ if (!replayOn || !recordedFixture) return data;
71
+ if ((globalThis.__XRAY__?.replayMode ?? "prefer") === "missing-only" && data != null) return data;
72
+ return recordedFixture.data;
73
+ }
13
74
  /**
14
75
  * The plate to render: the freshest capture from the client store (hot-swapped
15
76
  * in place, no remount) when present, else the imported prop.
@@ -26,50 +87,119 @@ function useLivePlate(plate) {
26
87
  * `@hueest/xray/react` to this module in `serve`; production gets the hook-free
27
88
  * `react.tsx`.
28
89
  */
29
- function Skeleton({ plate, loading = false, suspense = false, fallback, delay, children }) {
90
+ function Skeleton(props) {
91
+ const { plate, loading = false, suspense = false, fallback, delay, children, data, captureWalker } = props;
92
+ const manual = props.root === "manual";
30
93
  const lightBoxOn = useLightBox();
94
+ const livePlate = useLivePlate(plate);
95
+ const rootRef = useRef(null);
96
+ const showSkeleton = useSkeletonTiming(loading, rootRef);
97
+ const [instant] = useState(() => isStitchLive(plate.name));
98
+ useEffect(() => {
99
+ if (!showSkeleton) return void 0;
100
+ return markStitchesLive(collectStitchNames(livePlate.chunks));
101
+ }, [showSkeleton, livePlate.chunks]);
31
102
  const skeleton = /* @__PURE__ */ jsx(PlateView, {
32
- plate: useLivePlate(plate),
33
- fallback
103
+ plate: livePlate,
104
+ fallback,
105
+ rootRef,
106
+ instant
34
107
  });
35
108
  if (lightBoxOn) return skeleton;
36
- if (suspense) return /* @__PURE__ */ jsx(Suspense, {
37
- fallback: skeleton,
38
- children: /* @__PURE__ */ jsx(Content, {
39
- plate,
40
- delay,
41
- children
42
- })
43
- });
44
- if (loading) return skeleton;
45
- return /* @__PURE__ */ jsx(Content, {
109
+ const content = /* @__PURE__ */ jsx(Content, {
46
110
  plate,
47
111
  delay,
112
+ manual,
113
+ data,
114
+ captureWalker,
48
115
  children
49
116
  });
117
+ if (suspense) return /* @__PURE__ */ jsx(Suspense, {
118
+ fallback: skeleton,
119
+ children: content
120
+ });
121
+ if (showSkeleton) return skeleton;
122
+ return content;
50
123
  }
51
124
  function Content(props) {
52
- if (globalThis.__XRAY__ === void 0) return /* @__PURE__ */ jsx(Fragment$1, { children: props.children });
125
+ const { children, data } = props;
126
+ if (globalThis.__XRAY__ === void 0) return /* @__PURE__ */ jsx(Fragment$1, { children: resolveContent(children, data) });
127
+ if (props.manual) return /* @__PURE__ */ jsx(ManualBoundary, { ...props });
53
128
  return /* @__PURE__ */ jsx(CaptureBoundary, { ...props });
54
129
  }
55
- function CaptureBoundary({ plate, delay, children }) {
130
+ /**
131
+ * `root="auto"` (default): the existing `display:contents` boundary wrapper marks
132
+ * the capture seam so a parent capture stops here and references this plate by
133
+ * name rather than re-serializing the content (ADR 0006). A render prop is called
134
+ * with the verbatim `data` and a no-op `ref` (the wrapper, not the consumer's
135
+ * element, carries the marker); its result renders inside the wrapper exactly as
136
+ * plain children do.
137
+ */
138
+ function CaptureBoundary({ plate, delay, children, data, captureWalker }) {
56
139
  const ref = useRef(null);
57
140
  useEffect(() => {
58
141
  const el = ref.current;
59
142
  if (!el) return void 0;
60
- return globalThis.__XRAY__?.captured(plate.name, el, { delay });
61
- }, [plate.name, delay]);
143
+ return globalThis.__XRAY__?.captured(plate.name, el, {
144
+ delay,
145
+ walker: captureWalker,
146
+ mode: "auto"
147
+ });
148
+ }, [
149
+ plate.name,
150
+ delay,
151
+ captureWalker
152
+ ]);
153
+ const effective = useFixtureData(plate.name, data, isRenderProp(children));
62
154
  return /* @__PURE__ */ jsx("div", {
63
155
  [BOUNDARY_ATTR]: plate.name,
64
156
  style: { display: "contents" },
65
157
  ref,
66
- children
158
+ children: resolveContent(children, effective)
67
159
  });
68
160
  }
161
+ /**
162
+ * `root="manual"` (ADR 0014): no wrapper. The render prop is handed a CALLBACK
163
+ * ref that, when spread onto the consumer's root element, writes the existing
164
+ * `data-xr-boundary` marker onto it and registers it as the capture root, so a
165
+ * parent capture reads the identical marker (`serialize.ts` does
166
+ * `BOUNDARY_ATTR ?? ROOT_ATTR`) and cannot tell a manual root from an auto one.
167
+ *
168
+ * If the consumer forgets to spread the ref, the marker never lands and the
169
+ * parent capture descends into and silently swallows this subtree's DOM: the
170
+ * exact stitch-isolation breakage ADR 0006 prevents, and the worst failure xray
171
+ * can produce. A dev-time effect asserts the ref fired and loudly `console.error`s
172
+ * when it did not.
173
+ */
174
+ function ManualBoundary({ plate, delay, children, data, captureWalker }) {
175
+ const disposeRef = useRef(void 0);
176
+ const attachedRef = useRef(false);
177
+ const ref = useCallback((el) => {
178
+ disposeRef.current?.();
179
+ disposeRef.current = void 0;
180
+ if (!el) return;
181
+ el.setAttribute(BOUNDARY_ATTR, plate.name);
182
+ attachedRef.current = true;
183
+ disposeRef.current = globalThis.__XRAY__?.captured(plate.name, el, {
184
+ delay,
185
+ walker: captureWalker,
186
+ mode: "manual"
187
+ });
188
+ }, [
189
+ plate.name,
190
+ delay,
191
+ captureWalker
192
+ ]);
193
+ useEffect(() => () => disposeRef.current?.(), []);
194
+ useEffect(() => {
195
+ if (!attachedRef.current) console.error(`[xray] <Skeleton root="manual" plate="${plate.name}"> never received its \`ref\`: spread it onto your root element, or capture leaks into the parent plate (ADR 0006).`);
196
+ }, [plate.name]);
197
+ return /* @__PURE__ */ jsx(Fragment$1, { children: resolveContent(children, useFixtureData(plate.name, data, isRenderProp(children)), ref) });
198
+ }
69
199
  /** Render a plate's skeleton, seeding the live store from its resolved refs so a
70
200
  * stitch resolves before its child is re-captured and survives a parent
71
201
  * hot-swap (which pushes a refs-less plate). Never overwrites a live capture. */
72
- function PlateView({ plate, fallback, nested = false }) {
202
+ function PlateView({ plate, fallback, nested = false, rootRef, instant }) {
73
203
  useEffect(() => {
74
204
  const store = globalThis.__XRAY__?.plates;
75
205
  if (!store || !plate.refs) return;
@@ -79,7 +209,7 @@ function PlateView({ plate, fallback, nested = false }) {
79
209
  return renderPlate(plate, (name) => /* @__PURE__ */ jsx(RefNode, {
80
210
  name,
81
211
  staticRefs: plate.refs
82
- }), nested);
212
+ }), nested, rootRef, instant);
83
213
  }
84
214
  /**
85
215
  * A stitch in dev: resolve the child plate from the live store (so re-capturing
package/dist/react.js CHANGED
@@ -1,5 +1,5 @@
1
- import { t as renderPlate } from "./react.core-CqnDjfAJ.js";
2
- import { Suspense } from "react";
1
+ import { a as renderPlate, i as markStitchesLive, o as resolveContent, r as isStitchLive, s as useSkeletonTiming, t as collectStitchNames } from "./react.core-DMIhXHZF.js";
2
+ import { Suspense, useEffect, useRef, useState } from "react";
3
3
  import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
4
4
  //#region src/react.tsx
5
5
  /**
@@ -29,30 +29,42 @@ import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
29
29
  * In production this component ships no capture client, HUD, Light Box, store,
30
30
  * or subscriptions. Stitches resolve from the statically imported `refs` graph.
31
31
  */
32
- function Skeleton({ plate, loading = false, suspense = false, fallback, children }) {
32
+ function Skeleton(props) {
33
+ const { plate, loading = false, suspense = false, fallback, children, data } = props;
34
+ const rootRef = useRef(null);
35
+ const showSkeleton = useSkeletonTiming(loading, rootRef);
36
+ const [instant] = useState(() => isStitchLive(plate.name));
37
+ useEffect(() => {
38
+ if (!showSkeleton) return void 0;
39
+ return markStitchesLive(collectStitchNames(plate.chunks));
40
+ }, [showSkeleton, plate.chunks]);
33
41
  const skeleton = /* @__PURE__ */ jsx(PlateView, {
34
42
  plate,
35
- fallback
43
+ fallback,
44
+ rootRef,
45
+ instant
36
46
  });
37
47
  if (suspense) return /* @__PURE__ */ jsx(Suspense, {
38
48
  fallback: skeleton,
39
- children
49
+ children: resolveContent(children, data)
40
50
  });
41
- if (loading) return skeleton;
42
- return /* @__PURE__ */ jsx(Fragment$1, { children });
51
+ if (showSkeleton) return skeleton;
52
+ return /* @__PURE__ */ jsx(Fragment$1, { children: resolveContent(children, data) });
43
53
  }
44
54
  /** Render a plate's skeleton, mounting each stitch's child plate from `refs`. */
45
- function PlateView({ plate, fallback }) {
55
+ function PlateView({ plate, fallback, rootRef, instant }) {
46
56
  if (!plate.chunks) return /* @__PURE__ */ jsx(Fragment$1, { children: fallback ?? null });
47
- return renderStatic(plate, false);
57
+ return renderStatic(plate, false, rootRef, instant);
48
58
  }
49
59
  /** Resolve stitches statically through the import graph (ADR 0006) — pure
50
- * recursion, no store, no subscription. An unknown child renders nothing. */
51
- function renderStatic(plate, nested) {
60
+ * recursion, no store, no subscription. An unknown child renders nothing. The
61
+ * `instant` flag (ADR 0020) rides the outermost root only; nested stitches pass
62
+ * none, so `renderPlate` never marks a nested root. */
63
+ function renderStatic(plate, nested, rootRef, instant) {
52
64
  return renderPlate(plate, (name) => {
53
65
  const child = plate.refs?.[name];
54
66
  return child ? renderStatic(child, true) : null;
55
- }, nested);
67
+ }, nested, rootRef, instant);
56
68
  }
57
69
  //#endregion
58
70
  export { Skeleton };