@astryxdesign/core 0.7.2-canary.a5c7e4a → 0.7.2-canary.b4b905b

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.
@@ -1 +1 @@
1
- {"version":3,"file":"useLayer.d.ts","sourceRoot":"","sources":["../../src/Layer/useLayer.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,EAOZ,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAyHnD;;GAEG;AACH,UAAU,kBAAkB;IAC1B,aAAa,EAAE,KAAK,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;IACtD;;;OAGG;IACH,cAAc,EAAE,KAAK,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;CACtD;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,KAAK,CAAC;AAEjE;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAExD;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAClC;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;OAEG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;IACpD;;OAEG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;CACrD;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV;;OAEG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CAC7B;AAED;;GAEG;AACH,UAAU,gBAAgB;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC3D,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAkB,SAAQ,gBAAgB;IACzD,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;OAEG;IACH,EAAE,EAAE,MAAM,CAAC;IAEX;;;OAGG;IACH,MAAM,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,kBAAkB,KAAK,SAAS,CAAC;CACxE;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;OAEG;IACH,GAAG,EAAE,SAAS,CAAC;IAEf;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;OAEG;IACH,EAAE,EAAE,MAAM,CAAC;IAEX;;;OAGG;IACH,MAAM,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,gBAAgB,KAAK,SAAS,CAAC;CACrE;AAED,UAAU,mBAAmB;IAC3B,gBAAgB,EAAE,MAAM,OAAO,CAAC;CACjC;AAED,KAAK,0BAA0B,GAAG,kBAAkB,GAAG,mBAAmB,CAAC;AAC3E,KAAK,wBAAwB,GAAG,gBAAgB,GAAG,mBAAmB,CAAC;AAwHvE;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,GAAE,cAAwB,EACnC,SAAS,GAAE,cAAyB,GACnC,MAAM,CAiBR;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,cAAc,EACzB,SAAS,EAAE,cAAc,EACzB,cAAc,EAAE,OAAO,GACtB,KAAK,CAAC,aAAa,CAQrB;AA4BD;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,OAAO,GACd,kBAAkB,CAoCpB;AAupBD,wBAAgB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,GAAG,kBAAkB,CAAC;AAC3E,wBAAgB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAAC;AAWvE,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,mBAAmB,GAC3B,0BAA0B,CAAC;AAC9B,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,iBAAiB,GACzB,wBAAwB,CAAC"}
1
+ {"version":3,"file":"useLayer.d.ts","sourceRoot":"","sources":["../../src/Layer/useLayer.tsx"],"names":[],"mappings":"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"}
@@ -464,8 +464,14 @@ function useLayerImplementation(options) {
464
464
  // hosted away from its trigger then still takes its sequential focus
465
465
  // order (and its popover nesting) from the trigger rather than from
466
466
  // its own DOM position. Browsers without the option ignore it.
467
+ // `source` is declared as an `HTMLElement`. A caller-rendered icon
468
+ // trigger is an `SVGElement`, whose dictionary conversion throws
469
+ // before the popover opens. Omitting the option for it still anchors
470
+ // the layer, because positioning reads the anchor-name pair, and only
471
+ // gives up the invoker's focus order.
472
+ const trigger = triggerRef.current instanceof HTMLElement ? triggerRef.current : undefined;
467
473
  popover.showPopover({
468
- source: triggerRef.current ?? undefined
474
+ source: trigger
469
475
  });
470
476
  });
471
477
  } else {
@@ -4,7 +4,8 @@ export type EntryAnimationPreset = 'slideDown' | 'slideUp' | 'fadeIn' | 'scaleIn
4
4
  * Returns a StyleX style for animating an element on mount.
5
5
  *
6
6
  * Only animates when the element is dynamically inserted after the initial
7
- * page paint. Elements rendered on page load are not animated.
7
+ * page paint. Elements rendered on page load, including server-rendered
8
+ * elements while they hydrate, are not animated.
8
9
  *
9
10
  * @example
10
11
  * ```
@@ -1 +1 @@
1
- {"version":3,"file":"useEntryAnimation.d.ts","sourceRoot":"","sources":["../../src/hooks/useEntryAnimation.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAoFnD,MAAM,MAAM,oBAAoB,GAC5B,WAAW,GACX,SAAS,GACT,QAAQ,GACR,SAAS,CAAC;AAEd;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,GAAE,oBAAkC,GACzC,YAAY,GAAG,IAAI,CAGrB"}
1
+ {"version":3,"file":"useEntryAnimation.d.ts","sourceRoot":"","sources":["../../src/hooks/useEntryAnimation.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAgFnD,MAAM,MAAM,oBAAoB,GAC9B,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEjD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,GAAE,oBAAkC,GACzC,YAAY,GAAG,IAAI,CAMrB"}
@@ -4,28 +4,26 @@
4
4
 
5
5
  /**
6
6
  * @file useEntryAnimation.ts
7
- * @input Uses React, StyleX, theme tokens
7
+ * @input Uses React, StyleX, theme tokens, useIsHydrating
8
8
  * @output Exports useEntryAnimation hook for mount-only entry animations
9
9
  * @position Hook utility; consumed by FieldStatus and available to consumers
10
10
  *
11
11
  * Provides entry animations for conditionally rendered elements.
12
12
  * Only animates when the element is dynamically mounted after the initial
13
13
  * page paint — statically rendered elements on page load are not animated.
14
+ * Server-rendered elements hydrate without the entry style and never play it
15
+ * (spec:AST-013 FR13).
14
16
  */
15
17
  import { useState } from 'react';
16
18
  import * as stylex from '@stylexjs/stylex';
17
19
  import "../theme/tokens.stylex.js";
18
20
  import { durationVars, easeVars, spacingVars } from "../theme/tokens.stylex.js";
21
+ import { useIsHydrating } from "./useIsHydrating.js";
19
22
 
20
- // Track whether the initial page paint has completed.
21
- // Elements rendered on page load should not animate;
22
- // only dynamically inserted ones should.
23
- //
24
- // NOTE: This module is marked 'use client' so it never runs on the server.
25
- // If the directive is removed, this flag will always be false during SSR,
26
- // and useState(() => initialPaintComplete) will capture false — meaning
27
- // animations won't run after hydration. Keep 'use client' or add an
28
- // effect-based fallback if SSR support is needed.
23
+ // Track whether the initial page paint has completed, so elements a
24
+ // client-only root renders on page load do not animate; only dynamically
25
+ // inserted ones should. Server-rendered elements are covered by
26
+ // useIsHydrating instead: hydration can run after this flag turns true.
29
27
  let initialPaintComplete = false;
30
28
  if (typeof window !== 'undefined') {
31
29
  requestAnimationFrame(() => {
@@ -70,7 +68,8 @@ const styles = {
70
68
  * Returns a StyleX style for animating an element on mount.
71
69
  *
72
70
  * Only animates when the element is dynamically inserted after the initial
73
- * page paint. Elements rendered on page load are not animated.
71
+ * page paint. Elements rendered on page load, including server-rendered
72
+ * elements while they hydrate, are not animated.
74
73
  *
75
74
  * @example
76
75
  * ```
@@ -79,6 +78,9 @@ const styles = {
79
78
  * ```
80
79
  */
81
80
  export function useEntryAnimation(preset = 'slideDown') {
82
- const [animate] = useState(() => initialPaintComplete);
81
+ const isHydrating = useIsHydrating();
82
+ // Captured once: a hydrated element must not start animating when React
83
+ // re-renders it with the client snapshot after hydration.
84
+ const [animate] = useState(() => !isHydrating && initialPaintComplete);
83
85
  return animate ? styles[preset] : null;
84
86
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Returns `true` during server render and during the client render that
3
+ * hydrates server HTML; `false` in every other client render.
4
+ */
5
+ export declare function useIsHydrating(): boolean;
6
+ //# sourceMappingURL=useIsHydrating.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIsHydrating.d.ts","sourceRoot":"","sources":["../../src/hooks/useIsHydrating.ts"],"names":[],"mappings":"AAwBA;;;GAGG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAExC"}
@@ -0,0 +1,30 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useIsHydrating.ts
7
+ * @input Uses React useSyncExternalStore
8
+ * @output Exports useIsHydrating (internal; not re-exported from hooks/index.ts)
9
+ * @position Internal utility; read by useEntryAnimation so markup that
10
+ * hydrates server HTML matches the server (spec:AST-013 FR9, FR13)
11
+ *
12
+ * React renders with the server snapshot on the server and again in the
13
+ * client render that hydrates server HTML, then re-renders with the client
14
+ * snapshot. Code that must render the server's markup while hydrating reads
15
+ * this instead of a first-paint flag, timer, animation frame, or mount Effect.
16
+ */
17
+ import { useSyncExternalStore } from 'react';
18
+
19
+ // Nothing ever changes, so there is nothing to subscribe to.
20
+ const subscribe = () => () => {};
21
+ const getSnapshot = () => false;
22
+ const getServerSnapshot = () => true;
23
+
24
+ /**
25
+ * Returns `true` during server render and during the client render that
26
+ * hydrates server HTML; `false` in every other client render.
27
+ */
28
+ export function useIsHydrating() {
29
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.a5c7e4a",
3
+ "version": "0.7.2-canary.b4b905b",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -787,8 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
- "@astryxdesign/a11y-spec": "0.7.2-canary.a5c7e4a",
791
- "@astryxdesign/cli": "0.7.2-canary.a5c7e4a",
790
+ "@astryxdesign/a11y-spec": "0.7.2-canary.b4b905b",
791
+ "@astryxdesign/cli": "0.7.2-canary.b4b905b",
792
792
  "@babel/cli": "^7.29.7",
793
793
  "@babel/core": "^7.29.7",
794
794
  "@babel/preset-react": "^7.29.7",
@@ -762,7 +762,16 @@ function useLayerImplementation(
762
762
  // hosted away from its trigger then still takes its sequential focus
763
763
  // order (and its popover nesting) from the trigger rather than from
764
764
  // its own DOM position. Browsers without the option ignore it.
765
- popover.showPopover({source: triggerRef.current ?? undefined});
765
+ // `source` is declared as an `HTMLElement`. A caller-rendered icon
766
+ // trigger is an `SVGElement`, whose dictionary conversion throws
767
+ // before the popover opens. Omitting the option for it still anchors
768
+ // the layer, because positioning reads the anchor-name pair, and only
769
+ // gives up the invoker's focus order.
770
+ const trigger =
771
+ triggerRef.current instanceof HTMLElement
772
+ ? triggerRef.current
773
+ : undefined;
774
+ popover.showPopover({source: trigger});
766
775
  });
767
776
  } else {
768
777
  popover.style.display = 'block';
@@ -183,6 +183,37 @@ describe('Tooltip', () => {
183
183
  });
184
184
  });
185
185
 
186
+ describe('icon-only trigger', () => {
187
+ // A rendered icon is an SVGElement, and ShowPopoverOptions.source is
188
+ // declared as an HTMLElement. Handing the icon to showPopover throws
189
+ // during dictionary conversion, before the popover opens, so the hint
190
+ // never appeared. (#7226)
191
+ it('opens an icon trigger without passing an SVGElement as the source', async () => {
192
+ const showSpy = vi.mocked(HTMLElement.prototype.showPopover);
193
+ showSpy.mockClear();
194
+
195
+ render(
196
+ <Tooltip content="Shared memory" isDefaultOpen>
197
+ <svg data-testid="icon" aria-hidden="true" />
198
+ </Tooltip>,
199
+ );
200
+
201
+ await waitFor(() => {
202
+ expect(showSpy).toHaveBeenCalled();
203
+ });
204
+
205
+ const [options] = showSpy.mock.calls[0];
206
+ // Compared as a boolean on purpose: a plain `toBeUndefined` failure makes
207
+ // the diff printer walk the SVGElement, which it cannot inspect.
208
+ expect(options?.source === undefined).toBe(true);
209
+
210
+ // The icon is still the trigger the ARIA tooltip pattern is wired to.
211
+ expect(screen.getByTestId('icon').getAttribute('aria-describedby')).toBe(
212
+ screen.getByRole('tooltip', {hidden: true}).id,
213
+ );
214
+ });
215
+ });
216
+
186
217
  describe('WCAG 1.4.13 — content on hover or focus', () => {
187
218
  it('dismisses on Escape while visible (dismissible)', async () => {
188
219
  const onOpenChange = vi.fn();
@@ -18,12 +18,12 @@ export const docs = {
18
18
  {
19
19
  name: 'entryStyle',
20
20
  type: 'StyleXStyles | null',
21
- description: 'A StyleX style object for the entry animation, or null if the element was rendered on initial page load (no animation needed).',
21
+ description: 'A StyleX style object for the entry animation, or null if the element was rendered on initial page load or hydrated from server HTML (no animation needed).',
22
22
  },
23
23
  ],
24
24
  usage: {
25
25
  description:
26
- 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing. Requires "use client"; does not support SSR.',
26
+ 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing. Server-rendered elements hydrate without the entry style and never animate; elements mounted after hydration animate.',
27
27
  bestPractices: [
28
28
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, or expanding sections.' },
29
29
  { guidance: true, description: 'Spread the returned style into stylex.props() alongside other styles.' },
@@ -39,7 +39,7 @@ export const docs = {
39
39
  /** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
40
40
  export const docsDense = {
41
41
  description:
42
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
42
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Server-rendered elements hydrate without entry style, never animate; elements mounted after hydration animate.',
43
43
  paramDescriptions: {
44
44
  preset: 'animation preset applied on mount.',
45
45
  },
@@ -48,7 +48,7 @@ export const docsDense = {
48
48
  },
49
49
  usage: {
50
50
  description:
51
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
51
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Server-rendered elements hydrate without entry style, never animate; elements mounted after hydration animate.',
52
52
  bestPractices: [
53
53
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, expanding sections.' },
54
54
  { guidance: true, description: 'Spread returned style into stylex.props() alongside other styles.' },
@@ -0,0 +1,143 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useEntryAnimation.ssr.test.tsx
5
+ * @input FieldStatus server markup, React hydration, and client mounts
6
+ * @output Proves server-rendered entry-animated elements hydrate without mismatches
7
+ * @position SSR acceptance test for useEntryAnimation
8
+ */
9
+
10
+ import {act, useState} from 'react';
11
+ import {afterEach, describe, expect, it, vi} from 'vitest';
12
+ import {createRoot, hydrateRoot} from 'react-dom/client';
13
+ import {renderToString} from 'react-dom/server';
14
+
15
+ // Each test imports a fresh copy of FieldStatus with the hook's first-frame
16
+ // callback held back, so server markup renders before the page "paints" (as on
17
+ // a real server) and `paint()` then runs that frame.
18
+ async function importFieldStatus() {
19
+ const frames: FrameRequestCallback[] = [];
20
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
21
+ frames.push(callback),
22
+ );
23
+ vi.resetModules();
24
+ const {FieldStatus} = await import('../FieldStatus');
25
+ vi.unstubAllGlobals();
26
+ const paint = () => {
27
+ frames.splice(0).forEach(callback => callback(performance.now()));
28
+ };
29
+ return {FieldStatus, paint};
30
+ }
31
+
32
+ function mountServerHTML(html: string): HTMLDivElement {
33
+ const container = document.createElement('div');
34
+ container.innerHTML = html;
35
+ document.body.appendChild(container);
36
+ return container;
37
+ }
38
+
39
+ function classesOf(element: Element | null): string[] {
40
+ return (element?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
41
+ }
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ vi.unstubAllGlobals();
46
+ document.body.replaceChildren();
47
+ });
48
+
49
+ describe('useEntryAnimation — SSR', () => {
50
+ it('hydrates server-rendered FieldStatus after the first paint without a mismatch', async () => {
51
+ const {FieldStatus, paint} = await importFieldStatus();
52
+ const tree = <FieldStatus type="error" message="x" />;
53
+ const serverHTML = renderToString(tree);
54
+ const container = mountServerHTML(serverHTML);
55
+ const serverClassName = container.firstElementChild?.className;
56
+ paint();
57
+
58
+ const consoleError = vi
59
+ .spyOn(console, 'error')
60
+ .mockImplementation(() => {});
61
+ const recoverableErrors: unknown[] = [];
62
+ let root!: ReturnType<typeof hydrateRoot>;
63
+ await act(async () => {
64
+ root = hydrateRoot(container, tree, {
65
+ onRecoverableError: error => recoverableErrors.push(error),
66
+ });
67
+ });
68
+
69
+ expect(
70
+ consoleError.mock.calls.filter(call =>
71
+ String(call[0] ?? '')
72
+ .toLowerCase()
73
+ .includes('hydrat'),
74
+ ),
75
+ ).toEqual([]);
76
+ expect(recoverableErrors).toEqual([]);
77
+ expect(container.firstElementChild?.className).toBe(serverClassName);
78
+
79
+ await act(async () => {
80
+ root.unmount();
81
+ });
82
+ });
83
+
84
+ it('still animates a client-only mount after the first paint', async () => {
85
+ const {FieldStatus, paint} = await importFieldStatus();
86
+ const staticClasses = classesOf(
87
+ mountServerHTML(renderToString(<FieldStatus type="error" message="x" />))
88
+ .firstElementChild,
89
+ );
90
+ paint();
91
+
92
+ const container = document.createElement('div');
93
+ document.body.appendChild(container);
94
+ const root = createRoot(container);
95
+ await act(async () => {
96
+ root.render(<FieldStatus type="error" message="x" />);
97
+ });
98
+
99
+ const clientClasses = classesOf(container.firstElementChild);
100
+ expect(clientClasses).toEqual(expect.arrayContaining(staticClasses));
101
+ expect(clientClasses.length).toBeGreaterThan(staticClasses.length);
102
+
103
+ await act(async () => {
104
+ root.unmount();
105
+ });
106
+ });
107
+
108
+ it('animates a FieldStatus mounted after hydration', async () => {
109
+ const {FieldStatus, paint} = await importFieldStatus();
110
+ let showSecond!: () => void;
111
+ function Form() {
112
+ const [second, setSecond] = useState(false);
113
+ showSecond = () => setSecond(true);
114
+ return (
115
+ <>
116
+ <FieldStatus type="error" message="first" />
117
+ {second ? <FieldStatus type="error" message="second" /> : null}
118
+ </>
119
+ );
120
+ }
121
+ const container = mountServerHTML(renderToString(<Form />));
122
+ const staticClasses = classesOf(container.firstElementChild);
123
+ paint();
124
+
125
+ let root!: ReturnType<typeof hydrateRoot>;
126
+ await act(async () => {
127
+ root = hydrateRoot(container, <Form />);
128
+ });
129
+ await act(async () => {
130
+ showSecond();
131
+ });
132
+
133
+ const mounted = container.children[1] ?? null;
134
+ expect(mounted).toHaveTextContent('second');
135
+ const mountedClasses = classesOf(mounted);
136
+ expect(mountedClasses).toEqual(expect.arrayContaining(staticClasses));
137
+ expect(mountedClasses.length).toBeGreaterThan(staticClasses.length);
138
+
139
+ await act(async () => {
140
+ root.unmount();
141
+ });
142
+ });
143
+ });
@@ -3,29 +3,27 @@
3
3
  'use client';
4
4
  /**
5
5
  * @file useEntryAnimation.ts
6
- * @input Uses React, StyleX, theme tokens
6
+ * @input Uses React, StyleX, theme tokens, useIsHydrating
7
7
  * @output Exports useEntryAnimation hook for mount-only entry animations
8
8
  * @position Hook utility; consumed by FieldStatus and available to consumers
9
9
  *
10
10
  * Provides entry animations for conditionally rendered elements.
11
11
  * Only animates when the element is dynamically mounted after the initial
12
12
  * page paint — statically rendered elements on page load are not animated.
13
+ * Server-rendered elements hydrate without the entry style and never play it
14
+ * (spec:AST-013 FR13).
13
15
  */
14
16
 
15
17
  import {useState} from 'react';
16
18
  import * as stylex from '@stylexjs/stylex';
17
19
  import type {StyleXStyles} from '@stylexjs/stylex';
18
20
  import {durationVars, easeVars, spacingVars} from '../theme/tokens.stylex';
21
+ import {useIsHydrating} from './useIsHydrating';
19
22
 
20
- // Track whether the initial page paint has completed.
21
- // Elements rendered on page load should not animate;
22
- // only dynamically inserted ones should.
23
- //
24
- // NOTE: This module is marked 'use client' so it never runs on the server.
25
- // If the directive is removed, this flag will always be false during SSR,
26
- // and useState(() => initialPaintComplete) will capture false — meaning
27
- // animations won't run after hydration. Keep 'use client' or add an
28
- // effect-based fallback if SSR support is needed.
23
+ // Track whether the initial page paint has completed, so elements a
24
+ // client-only root renders on page load do not animate; only dynamically
25
+ // inserted ones should. Server-rendered elements are covered by
26
+ // useIsHydrating instead: hydration can run after this flag turns true.
29
27
  let initialPaintComplete = false;
30
28
  if (typeof window !== 'undefined') {
31
29
  requestAnimationFrame(() => {
@@ -99,16 +97,14 @@ const styles = stylex.create({
99
97
  });
100
98
 
101
99
  export type EntryAnimationPreset =
102
- | 'slideDown'
103
- | 'slideUp'
104
- | 'fadeIn'
105
- | 'scaleIn';
100
+ 'slideDown' | 'slideUp' | 'fadeIn' | 'scaleIn';
106
101
 
107
102
  /**
108
103
  * Returns a StyleX style for animating an element on mount.
109
104
  *
110
105
  * Only animates when the element is dynamically inserted after the initial
111
- * page paint. Elements rendered on page load are not animated.
106
+ * page paint. Elements rendered on page load, including server-rendered
107
+ * elements while they hydrate, are not animated.
112
108
  *
113
109
  * @example
114
110
  * ```
@@ -119,6 +115,9 @@ export type EntryAnimationPreset =
119
115
  export function useEntryAnimation(
120
116
  preset: EntryAnimationPreset = 'slideDown',
121
117
  ): StyleXStyles | null {
122
- const [animate] = useState(() => initialPaintComplete);
118
+ const isHydrating = useIsHydrating();
119
+ // Captured once: a hydrated element must not start animating when React
120
+ // re-renders it with the client snapshot after hydration.
121
+ const [animate] = useState(() => !isHydrating && initialPaintComplete);
123
122
  return animate ? styles[preset] : null;
124
123
  }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useIsHydrating.test.tsx
5
+ * @input useIsHydrating, React server render, hydration, and client roots
6
+ * @output Proves the hook is true only on the server and while hydrating
7
+ * @position Unit test for the internal hydration-state utility
8
+ */
9
+
10
+ import {act, useState} from 'react';
11
+ import {afterEach, describe, expect, it} from 'vitest';
12
+ import {createRoot, hydrateRoot} from 'react-dom/client';
13
+ import {renderToString} from 'react-dom/server';
14
+ import {useIsHydrating} from './useIsHydrating';
15
+
16
+ const seen: boolean[] = [];
17
+
18
+ function Probe() {
19
+ const isHydrating = useIsHydrating();
20
+ seen.push(isHydrating);
21
+ return <span data-hydrating={String(isHydrating)} />;
22
+ }
23
+
24
+ afterEach(() => {
25
+ seen.length = 0;
26
+ document.body.replaceChildren();
27
+ });
28
+
29
+ describe('useIsHydrating', () => {
30
+ it('is true on the server', () => {
31
+ expect(renderToString(<Probe />)).toContain('data-hydrating="true"');
32
+ });
33
+
34
+ it('is true while hydrating, then false after hydration', async () => {
35
+ const container = document.createElement('div');
36
+ container.innerHTML = renderToString(<Probe />);
37
+ document.body.appendChild(container);
38
+ seen.length = 0;
39
+
40
+ let root!: ReturnType<typeof hydrateRoot>;
41
+ await act(async () => {
42
+ root = hydrateRoot(container, <Probe />);
43
+ });
44
+
45
+ expect(seen[0]).toBe(true);
46
+ expect(seen.at(-1)).toBe(false);
47
+ expect(container.querySelector('span')).toHaveAttribute(
48
+ 'data-hydrating',
49
+ 'false',
50
+ );
51
+ await act(async () => root.unmount());
52
+ });
53
+
54
+ it('is false in a client-only root and for elements mounted after hydration', async () => {
55
+ let show!: () => void;
56
+ function Later() {
57
+ const [isShown, setIsShown] = useState(false);
58
+ show = () => setIsShown(true);
59
+ return isShown ? <Probe /> : null;
60
+ }
61
+ const container = document.createElement('div');
62
+ document.body.appendChild(container);
63
+ const root = createRoot(container);
64
+ await act(async () => root.render(<Later />));
65
+ await act(async () => show());
66
+
67
+ expect(seen).toEqual([false]);
68
+ await act(async () => root.unmount());
69
+ });
70
+ });
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useIsHydrating.ts
7
+ * @input Uses React useSyncExternalStore
8
+ * @output Exports useIsHydrating (internal; not re-exported from hooks/index.ts)
9
+ * @position Internal utility; read by useEntryAnimation so markup that
10
+ * hydrates server HTML matches the server (spec:AST-013 FR9, FR13)
11
+ *
12
+ * React renders with the server snapshot on the server and again in the
13
+ * client render that hydrates server HTML, then re-renders with the client
14
+ * snapshot. Code that must render the server's markup while hydrating reads
15
+ * this instead of a first-paint flag, timer, animation frame, or mount Effect.
16
+ */
17
+
18
+ import {useSyncExternalStore} from 'react';
19
+
20
+ // Nothing ever changes, so there is nothing to subscribe to.
21
+ const subscribe = () => () => {};
22
+ const getSnapshot = () => false;
23
+ const getServerSnapshot = () => true;
24
+
25
+ /**
26
+ * Returns `true` during server render and during the client render that
27
+ * hydrates server HTML; `false` in every other client render.
28
+ */
29
+ export function useIsHydrating(): boolean {
30
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
31
+ }