eidotter 0.37.2 → 0.37.3

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.
@@ -28,15 +28,38 @@ export interface RetroEffectsProps {
28
28
  * it, and a warm phosphor glow settles. Skipped entirely under
29
29
  * prefers-reduced-motion. Opt-in; intended as the portfolio-wide launch
30
30
  * pattern (DMNC-1047).
31
+ *
32
+ * The sequence opens with a full-viewport opaque blackout that is part of the
33
+ * server-rendered HTML, so the page is never visible before the turn-on — the
34
+ * animation is the reveal, with no flash of content before boot (DMNC-1184).
35
+ * The open is pure CSS, so it works before hydration and with JS disabled
36
+ * (the cover self-resolves; nothing stays hidden).
37
+ *
38
+ * Assumes the default `powered` (on). Combining `boot` with `powered={false}`
39
+ * is contradictory — the power-off transform collapses the whole overlay,
40
+ * including the boot cover — so the no-flash guarantee does not apply there.
31
41
  */
32
42
  boot?: boolean;
33
43
  /**
34
44
  * Gate `boot` to once per browser tab/session (via sessionStorage). The boot
35
- * sequence plays on the first load of a tab and is suppressed on every
36
- * subsequent mount within the same tab — both SPA route changes and MPA
37
- * full-reload navigation. A new tab/visit replays it; a hard refresh does not.
38
- * No-op unless `boot` is also set. Falls back to playing if storage is
39
- * unavailable (e.g. private mode).
45
+ * sequence plays on the first load of a tab; on later mounts within the same
46
+ * tab the session flag suppresses it. A new tab/visit replays it. No-op unless
47
+ * `boot` is also set. Falls back to playing if storage is unavailable (e.g.
48
+ * private mode).
49
+ *
50
+ * **SSR caveat (DMNC-1184):** because the no-flash cover ships in the server
51
+ * HTML and the server can't read `sessionStorage`, suppression on a returning
52
+ * visitor is decided post-hydration. The practical effect by navigation type:
53
+ * - **SPA route change** (overlay lives in a persistent layout, no remount):
54
+ * never shows the cover — fully suppressed, as before.
55
+ * - **Full reload / hard refresh** (a returning tab re-mounts the overlay):
56
+ * the SSR cover paints, then JS tears it down on hydration. On a fast host
57
+ * that's a brief blackout (the panels' 140ms open delay means the turn-on
58
+ * usually isn't reached before teardown); only a slow hydration lets the
59
+ * turn-on visibly replay. It never flashes bare content.
60
+ * This is the deliberate trade for a flash-free first paint; eliminating the
61
+ * brief returning-visitor blackout entirely needs server-visible session state
62
+ * (a cookie or a pre-paint inline script), which is consumer-side.
40
63
  *
41
64
  * Like `boot`, this is evaluated once on mount; changing it afterwards has no
42
65
  * effect (remount the component — e.g. via `key` — to re-evaluate).
@@ -42,11 +42,18 @@ const markBootedThisSession = (key) => {
42
42
  export const RetroEffects = ({ scanlines = true, glow = true, flicker = true, bloom = false, powered = true, boot = false, bootOnce = false, bootStorageKey = DEFAULT_BOOT_STORAGE_KEY, intensity = 1, className, onPowerStateChange, onPowerOn, onPowerOff, onBootComplete, }) => {
43
43
  const prevPoweredRef = useRef(powered);
44
44
  const [powerState, setPowerState] = useState(powered ? 'on' : 'off');
45
- // Non-gated boot keeps its on-mount behavior; the gated (bootOnce) path starts
46
- // un-booted and is decided post-mount so SSR/first paint never render a stale
47
- // overlay (no hydration mismatch on reload when the flag is already set).
48
- // (Non-gated boot is intentionally NOT SSR-gated — it plays on every mount.)
49
- const [booting, setBooting] = useState(boot && !bootOnce);
45
+ // The opaque boot cover renders on first paint for BOTH the gated and
46
+ // non-gated paths whenever `boot` is set. This is the no-FOUC fix (DMNC-1184):
47
+ // the full-viewport panels are present in the server HTML, so the page is
48
+ // never visible before the turn-on — the animation is the reveal. `booting`
49
+ // mirrors the server value on the client's first render (no sessionStorage
50
+ // read here), so there is no hydration mismatch. The gated (bootOnce) decision
51
+ // is made post-mount: if this tab already booted, the cover is torn down by
52
+ // the decision effect once it runs. The server can't read sessionStorage, so a
53
+ // returning visitor's full reload paints the cover and suppresses it on
54
+ // hydration (a brief blackout, not a bare-content flash) — see the bootOnce
55
+ // JSDoc for the SSR trade.
56
+ const [booting, setBooting] = useState(boot);
50
57
  // Boot completes exactly once per mount. Guards against the StrictMode dev
51
58
  // double-invoke of effects and the (theoretical) animationend + safety-timeout
52
59
  // race both calling through twice.
@@ -85,12 +92,16 @@ export const RetroEffects = ({ scanlines = true, glow = true, flicker = true, bl
85
92
  if (!boot || !bootOnce)
86
93
  return;
87
94
  if (hasBootedThisSession(bootStorageKeyRef.current)) {
88
- // Already shown this session — skip, but still signal completion so
89
- // consumers sequencing boot text off onBootComplete don't stall.
95
+ // Already shown this session — tear down the SSR cover (it was rendered to
96
+ // prevent the first-paint flash, DMNC-1184) and signal completion so
97
+ // consumers sequencing boot text off onBootComplete don't stall. No replay.
98
+ setBooting(false);
90
99
  signalBootComplete();
91
100
  return;
92
101
  }
93
- setBooting(true);
102
+ // First visit this tab — keep the cover up; it plays and records the session
103
+ // flag on completion (`completeBoot`). `booting` is already true from the
104
+ // initial state, so there is nothing to set here.
94
105
  // Mount-only: gating is a first-load decision, not reactive to prop churn.
95
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
96
107
  }, []);
@@ -100,6 +111,11 @@ export const RetroEffects = ({ scanlines = true, glow = true, flicker = true, bl
100
111
  useEffect(() => {
101
112
  if (!booting)
102
113
  return;
114
+ // Already settled (e.g. the gated already-booted branch tore the SSR cover
115
+ // down and signalled in the decision effect, which runs first): don't arm a
116
+ // safety timer that we'd immediately clear on the next render.
117
+ if (bootSignaledRef.current)
118
+ return;
103
119
  if (prefersReducedMotion()) {
104
120
  completeBoot();
105
121
  return;