@seed-design/stackflow 1.1.23 → 1.1.25

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.
@@ -82,6 +82,34 @@ function clearAllStyles(t) {
82
82
  for (const el of all) clearStyles(el);
83
83
  }
84
84
  /**
85
+ * Remove all leftover inline styles from the top activity once the
86
+ * transition has settled (globalTransitionState === "idle").
87
+ *
88
+ * The top + behind pair model only handles the single immediately adjacent
89
+ * behind layer. When transitions overlap (concurrent pop, swipe-back race,
90
+ * etc.) the landing screen can get stuck with the temporary styles of a
91
+ * "behind" or an "exiting top" — the layer stays at -30% and shifts by 1/3,
92
+ * or the appBar root stays at opacity 0 (setPostExitPositions) and the whole
93
+ * app bar disappears.
94
+ *
95
+ * At idle the top must always be in a clean default state (setIdlePositions
96
+ * only clears the top and styles the behind), so wipe any leftover inline
97
+ * back to the CSS defaults (layer 0%, appBar visible). The behind is left
98
+ * untouched, and it's a no-op if already clean.
99
+ */
100
+ function clearTopActivityStyles(stackEl) {
101
+ const t = findTransitionTargets(stackEl);
102
+ const topParts = [
103
+ t.topLayer,
104
+ t.topDim,
105
+ t.topTitle,
106
+ t.topAppBarRoot,
107
+ t.topAppBarBackground,
108
+ ...t.topIcons
109
+ ];
110
+ for (const el of topParts) clearStyles(el);
111
+ }
112
+ /**
85
113
  * Set idle positions after push completes.
86
114
  * Pattern: clear everything first, then set only non-default positions.
87
115
  */
@@ -139,6 +167,7 @@ function applySwipeStyles(t, displacement, ratio) {
139
167
  //#endregion
140
168
  exports.applySwipeStyles = applySwipeStyles;
141
169
  exports.clearAllStyles = clearAllStyles;
170
+ exports.clearTopActivityStyles = clearTopActivityStyles;
142
171
  exports.findTransitionTargets = findTransitionTargets;
143
172
  exports.readTransitionStyle = readTransitionStyle;
144
173
  exports.setIdlePositions = setIdlePositions;
@@ -30,6 +30,23 @@ export declare function setOpacity(el: HTMLElement | null, value: string): void;
30
30
  * This is the foundation — call before setting any specific positions.
31
31
  */
32
32
  export declare function clearAllStyles(t: TransitionTargets): void;
33
+ /**
34
+ * Remove all leftover inline styles from the top activity once the
35
+ * transition has settled (globalTransitionState === "idle").
36
+ *
37
+ * The top + behind pair model only handles the single immediately adjacent
38
+ * behind layer. When transitions overlap (concurrent pop, swipe-back race,
39
+ * etc.) the landing screen can get stuck with the temporary styles of a
40
+ * "behind" or an "exiting top" — the layer stays at -30% and shifts by 1/3,
41
+ * or the appBar root stays at opacity 0 (setPostExitPositions) and the whole
42
+ * app bar disappears.
43
+ *
44
+ * At idle the top must always be in a clean default state (setIdlePositions
45
+ * only clears the top and styles the behind), so wipe any leftover inline
46
+ * back to the CSS defaults (layer 0%, appBar visible). The behind is left
47
+ * untouched, and it's a no-op if already clean.
48
+ */
49
+ export declare function clearTopActivityStyles(stackEl: HTMLElement): void;
33
50
  /**
34
51
  * Set idle positions after push completes.
35
52
  * Pattern: clear everything first, then set only non-default positions.
@@ -82,6 +82,34 @@ function clearAllStyles(t) {
82
82
  for (const el of all) clearStyles(el);
83
83
  }
84
84
  /**
85
+ * Remove all leftover inline styles from the top activity once the
86
+ * transition has settled (globalTransitionState === "idle").
87
+ *
88
+ * The top + behind pair model only handles the single immediately adjacent
89
+ * behind layer. When transitions overlap (concurrent pop, swipe-back race,
90
+ * etc.) the landing screen can get stuck with the temporary styles of a
91
+ * "behind" or an "exiting top" — the layer stays at -30% and shifts by 1/3,
92
+ * or the appBar root stays at opacity 0 (setPostExitPositions) and the whole
93
+ * app bar disappears.
94
+ *
95
+ * At idle the top must always be in a clean default state (setIdlePositions
96
+ * only clears the top and styles the behind), so wipe any leftover inline
97
+ * back to the CSS defaults (layer 0%, appBar visible). The behind is left
98
+ * untouched, and it's a no-op if already clean.
99
+ */
100
+ function clearTopActivityStyles(stackEl) {
101
+ const t = findTransitionTargets(stackEl);
102
+ const topParts = [
103
+ t.topLayer,
104
+ t.topDim,
105
+ t.topTitle,
106
+ t.topAppBarRoot,
107
+ t.topAppBarBackground,
108
+ ...t.topIcons
109
+ ];
110
+ for (const el of topParts) clearStyles(el);
111
+ }
112
+ /**
85
113
  * Set idle positions after push completes.
86
114
  * Pattern: clear everything first, then set only non-default positions.
87
115
  */
@@ -137,4 +165,4 @@ function applySwipeStyles(t, displacement, ratio) {
137
165
  for (const icon of t.behindIcons) setOpacity(icon, `${ratio}`);
138
166
  }
139
167
  //#endregion
140
- export { applySwipeStyles, clearAllStyles, findTransitionTargets, readTransitionStyle, setIdlePositions, setOpacity, setPostExitPositions, setTransform };
168
+ export { applySwipeStyles, clearAllStyles, clearTopActivityStyles, findTransitionTargets, readTransitionStyle, setIdlePositions, setOpacity, setPostExitPositions, setTransform };
@@ -4,6 +4,7 @@ const require_useTopActivity = require("../private/useTopActivity.cjs");
4
4
  const require_dom = require("./dom.cjs");
5
5
  const require_animation = require("./animation.cjs");
6
6
  let react = require("react");
7
+ let _stackflow_react = require("@stackflow/react");
7
8
  //#region src/primitive/GlobalInteraction/useGlobalInteraction.ts
8
9
  var IDLE_CONTEXT = Object.freeze({
9
10
  x0: 0,
@@ -135,6 +136,7 @@ function useGlobalInteraction() {
135
136
  setSwipeBackState
136
137
  ]);
137
138
  const topActivity = require_useTopActivity.useTopActivity();
139
+ const stack = (0, _stackflow_react.useStack)();
138
140
  const prevTransitionStateRef = (0, react.useRef)(topActivity.transitionState);
139
141
  (0, react.useLayoutEffect)(() => {
140
142
  const prev = prevTransitionStateRef.current;
@@ -183,6 +185,12 @@ function useGlobalInteraction() {
183
185
  stopRunningAnims,
184
186
  cancelPendingPushRAF
185
187
  ]);
188
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
189
+ (0, react.useLayoutEffect)(() => {
190
+ if (stack?.globalTransitionState !== "idle") return;
191
+ const stackEl = stackRef.current;
192
+ if (stackEl) require_dom.clearTopActivityStyles(stackEl);
193
+ }, [stack?.globalTransitionState, topActivityId]);
186
194
  (0, react.useEffect)(() => {
187
195
  return () => {
188
196
  cancelPendingPushRAF();
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { useTopActivity } from "../private/useTopActivity.js";
3
- import { applySwipeStyles, clearAllStyles, findTransitionTargets, readTransitionStyle, setIdlePositions, setPostExitPositions } from "./dom.js";
3
+ import { applySwipeStyles, clearAllStyles, clearTopActivityStyles, findTransitionTargets, readTransitionStyle, setIdlePositions, setPostExitPositions } from "./dom.js";
4
4
  import { animateSwipeCancel, animateSwipeComplete, animateTransition, cancelAll, scrubAppBarBackground } from "./animation.js";
5
5
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
6
+ import { useStack } from "@stackflow/react";
6
7
  //#region src/primitive/GlobalInteraction/useGlobalInteraction.ts
7
8
  var IDLE_CONTEXT = Object.freeze({
8
9
  x0: 0,
@@ -134,6 +135,7 @@ function useGlobalInteraction() {
134
135
  setSwipeBackState
135
136
  ]);
136
137
  const topActivity = useTopActivity();
138
+ const stack = useStack();
137
139
  const prevTransitionStateRef = useRef(topActivity.transitionState);
138
140
  useLayoutEffect(() => {
139
141
  const prev = prevTransitionStateRef.current;
@@ -182,6 +184,12 @@ function useGlobalInteraction() {
182
184
  stopRunningAnims,
183
185
  cancelPendingPushRAF
184
186
  ]);
187
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
188
+ useLayoutEffect(() => {
189
+ if (stack?.globalTransitionState !== "idle") return;
190
+ const stackEl = stackRef.current;
191
+ if (stackEl) clearTopActivityStyles(stackEl);
192
+ }, [stack?.globalTransitionState, topActivityId]);
185
193
  useEffect(() => {
186
194
  return () => {
187
195
  cancelPendingPushRAF();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/stackflow",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -36,7 +36,7 @@
36
36
  "clsx": "^2.1.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@seed-design/css": "1.2.13",
39
+ "@seed-design/css": "1.2.15",
40
40
  "@stackflow/core": "^1.2.0",
41
41
  "@stackflow/react": "^1.5.1",
42
42
  "@types/react": "^19.2.2",
@@ -138,10 +138,13 @@ function collectAnimations(anims: (Animation | null)[], durationMs: number): Ani
138
138
 
139
139
  // ─── iOS Slide ──────────────────────────────────────────────────────────────
140
140
 
141
- interface TitleKeyframe {
141
+ // Intersect with the DOM `Keyframe` so values of this type satisfy the
142
+ // `[property: string]` index signature `el.animate()` expects, while still
143
+ // guaranteeing `opacity` and `transform` are present.
144
+ type TitleKeyframe = Keyframe & {
142
145
  opacity: string;
143
146
  transform: string;
144
- }
147
+ };
145
148
 
146
149
  interface IosPositions {
147
150
  topLayer: string;
@@ -127,6 +127,35 @@ export function clearAllStyles(t: TransitionTargets) {
127
127
  for (const el of all) clearStyles(el);
128
128
  }
129
129
 
130
+ /**
131
+ * Remove all leftover inline styles from the top activity once the
132
+ * transition has settled (globalTransitionState === "idle").
133
+ *
134
+ * The top + behind pair model only handles the single immediately adjacent
135
+ * behind layer. When transitions overlap (concurrent pop, swipe-back race,
136
+ * etc.) the landing screen can get stuck with the temporary styles of a
137
+ * "behind" or an "exiting top" — the layer stays at -30% and shifts by 1/3,
138
+ * or the appBar root stays at opacity 0 (setPostExitPositions) and the whole
139
+ * app bar disappears.
140
+ *
141
+ * At idle the top must always be in a clean default state (setIdlePositions
142
+ * only clears the top and styles the behind), so wipe any leftover inline
143
+ * back to the CSS defaults (layer 0%, appBar visible). The behind is left
144
+ * untouched, and it's a no-op if already clean.
145
+ */
146
+ export function clearTopActivityStyles(stackEl: HTMLElement) {
147
+ const t = findTransitionTargets(stackEl);
148
+ const topParts = [
149
+ t.topLayer,
150
+ t.topDim,
151
+ t.topTitle,
152
+ t.topAppBarRoot,
153
+ t.topAppBarBackground,
154
+ ...t.topIcons,
155
+ ];
156
+ for (const el of topParts) clearStyles(el);
157
+ }
158
+
130
159
  /**
131
160
  * Set idle positions after push completes.
132
161
  * Pattern: clear everything first, then set only non-default positions.
@@ -0,0 +1,159 @@
1
+ import { aggregate, makeEvent, type DomainEvent, type Stack } from "@stackflow/core";
2
+ import { render } from "@testing-library/react";
3
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
4
+ import { appBarAnatomy } from "../AppBar/anatomy";
5
+ import { appScreenAnatomy } from "../AppScreen/anatomy";
6
+
7
+ // `useGlobalInteraction` (and the `useTopActivity` it calls) only consume
8
+ // `useStack` from @stackflow/react. Mocking just that lets the test feed real
9
+ // Stack snapshots — built by @stackflow/core's own reducer — without pulling in
10
+ // a renderer plugin. Registered before the dynamic import below so the hook
11
+ // picks it up.
12
+ let currentStack: Stack;
13
+ mock.module("@stackflow/react", () => ({ useStack: () => currentStack }));
14
+
15
+ const { useGlobalInteraction } = await import("./useGlobalInteraction");
16
+ const { findTransitionTargets, setIdlePositions } = await import("./dom");
17
+
18
+ // happy-dom ships no WAAPI. A `finished` that never settles models an
19
+ // animation that is still in flight, which is all these tests need.
20
+ Element.prototype.animate = (() => ({
21
+ finished: new Promise<void>(() => {}),
22
+ cancel() {},
23
+ })) as Element["animate"];
24
+
25
+ // ─── Real stack snapshots ───────────────────────────────────────────────────
26
+
27
+ const T0 = 1_000_000;
28
+ const TRANSITION_DURATION = 350;
29
+
30
+ /** Zero-padded so the reducer's lexicographic sort by id matches insertion order. */
31
+ function evt<T extends DomainEvent["name"]>(
32
+ seq: number,
33
+ name: T,
34
+ params: Omit<Extract<DomainEvent, { name: T }>, "id" | "name" | "eventDate">,
35
+ ) {
36
+ return makeEvent(name, {
37
+ ...params,
38
+ id: String(seq).padStart(4, "0"),
39
+ eventDate: T0 + seq,
40
+ });
41
+ }
42
+
43
+ const BASE_EVENTS: DomainEvent[] = [
44
+ evt(1, "Initialized", { transitionDuration: TRANSITION_DURATION }),
45
+ evt(2, "ActivityRegistered", { activityName: "Screen" }),
46
+ evt(3, "Pushed", { activityId: "a1", activityName: "Screen", activityParams: {} }),
47
+ evt(4, "Pushed", { activityId: "a2", activityName: "Screen", activityParams: {} }),
48
+ ];
49
+
50
+ /** Far enough past every eventDate that both pushes have settled. */
51
+ const SETTLED_AT = T0 + TRANSITION_DURATION * 10;
52
+
53
+ const POP_SEQ = 5;
54
+
55
+ function stackAfter(...extraEvents: DomainEvent[]): Stack {
56
+ return aggregate([...BASE_EVENTS, ...extraEvents], SETTLED_AT);
57
+ }
58
+
59
+ /** `pop({ animate: false })` — stackflow maps it to `skipExitActiveState`. */
60
+ const POP_WITHOUT_ANIMATION = evt(POP_SEQ, "Popped", { skipExitActiveState: true });
61
+
62
+ /** A plain animated `pop()`, still mid-flight at `SETTLED_AT`. */
63
+ const POP_WITH_ANIMATION = makeEvent("Popped", {
64
+ id: String(POP_SEQ).padStart(4, "0"),
65
+ eventDate: SETTLED_AT,
66
+ });
67
+
68
+ // ─── Harness ────────────────────────────────────────────────────────────────
69
+
70
+ function ActivityMarkup({ id, isTop }: { id: string; isTop: boolean }) {
71
+ return (
72
+ <section
73
+ data-part={appScreenAnatomy.activity}
74
+ data-activity-id={id}
75
+ data-transition-style="slideFromRightIOS"
76
+ {...(isTop ? { "data-activity-is-top": "" } : {})}
77
+ >
78
+ <div data-part={appScreenAnatomy.layer} data-testid={`${id}-layer`} />
79
+ <div data-part={appScreenAnatomy.dim} />
80
+ <div data-part={appBarAnatomy.root}>
81
+ <div data-part={appBarAnatomy.background} />
82
+ <div data-part={appBarAnatomy.main} data-testid={`${id}-title`} />
83
+ <div data-part={appBarAnatomy.icon} data-testid={`${id}-icon`} />
84
+ </div>
85
+ </section>
86
+ );
87
+ }
88
+
89
+ function Harness() {
90
+ const { stackRef } = useGlobalInteraction();
91
+
92
+ // Mirrors the core's `visibleActivities` filter — exit-done is unmounted.
93
+ const visible = currentStack.activities.filter((a) => a.transitionState !== "exit-done");
94
+
95
+ return (
96
+ <div ref={stackRef} data-testid="stack">
97
+ {visible.map((activity) => (
98
+ <ActivityMarkup key={activity.id} id={activity.id} isTop={activity.isTop} />
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ // ─── Tests ──────────────────────────────────────────────────────────────────
105
+
106
+ describe("useGlobalInteraction — settle safety-net", () => {
107
+ beforeEach(() => {
108
+ currentStack = stackAfter();
109
+ });
110
+
111
+ it("leaves the behind layer pinned at its idle offset while a2 is on top", () => {
112
+ // Premise check: a settled push is idle, with a2 on top.
113
+ expect(currentStack.globalTransitionState).toBe("idle");
114
+ expect(currentStack.activities.find((a) => a.isTop)?.id).toBe("a2");
115
+
116
+ const { getByTestId, rerender } = render(<Harness />);
117
+
118
+ // Exactly what the push's finished handler leaves behind.
119
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
120
+ rerender(<Harness />);
121
+
122
+ expect(getByTestId("a1-layer").style.transform).toBe("translate3d(-30%, 0, 0)");
123
+ });
124
+
125
+ it("clears the landing activity when pop({ animate: false }) skips exit-active", () => {
126
+ const { getByTestId, rerender } = render(<Harness />);
127
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
128
+
129
+ currentStack = stackAfter(POP_WITHOUT_ANIMATION);
130
+
131
+ // Premise: the transition state machine never moves — a1 just becomes top.
132
+ expect(currentStack.globalTransitionState).toBe("idle");
133
+ expect(currentStack.activities.find((a) => a.isTop)?.id).toBe("a1");
134
+
135
+ rerender(<Harness />);
136
+
137
+ expect(getByTestId("a1-layer").style.transform).toBe("");
138
+ expect(getByTestId("a1-title").style.transform).toBe("");
139
+ expect(getByTestId("a1-title").style.opacity).toBe("");
140
+ expect(getByTestId("a1-icon").style.opacity).toBe("");
141
+ });
142
+
143
+ it("does not touch inline styles while a transition is still in flight", () => {
144
+ const { getByTestId, rerender } = render(<Harness />);
145
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
146
+
147
+ currentStack = stackAfter(POP_WITH_ANIMATION);
148
+
149
+ // Premise: an animated pop parks a2 in exit-active, so the stack is loading.
150
+ expect(currentStack.globalTransitionState).toBe("loading");
151
+ expect(currentStack.activities.find((a) => a.id === "a2")?.transitionState).toBe("exit-active");
152
+
153
+ rerender(<Harness />);
154
+
155
+ // The WAAPI pop animation owns the unwind from here — the safety-net must
156
+ // stay out of the way until everything settles.
157
+ expect(getByTestId("a1-layer").style.transform).toBe("translate3d(-30%, 0, 0)");
158
+ });
159
+ });
@@ -1,3 +1,4 @@
1
+ import { useStack } from "@stackflow/react";
1
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
2
3
  import { useTopActivity } from "../private/useTopActivity";
3
4
  import {
@@ -7,6 +8,7 @@ import {
7
8
  readTransitionStyle,
8
9
  applySwipeStyles,
9
10
  clearAllStyles,
11
+ clearTopActivityStyles,
10
12
  setIdlePositions,
11
13
  setPostExitPositions,
12
14
  } from "./dom";
@@ -257,6 +259,7 @@ export function useGlobalInteraction() {
257
259
  }, [stopRunningAnims, stopAppBarBgScrub, setSwipeBackState]);
258
260
 
259
261
  const topActivity = useTopActivity();
262
+ const stack = useStack();
260
263
 
261
264
  // ── WAAPI push/pop transitions triggered by stackflow state changes ──
262
265
  const prevTransitionStateRef = useRef<string>(topActivity.transitionState);
@@ -326,6 +329,35 @@ export function useGlobalInteraction() {
326
329
  }
327
330
  }, [topActivity.transitionState, stopRunningAnims, cancelPendingPushRAF]);
328
331
 
332
+ // ── Settle safety-net ──
333
+ // The top + behind pair model only handles the single immediately adjacent
334
+ // behind layer. When transitions overlap (concurrent pop, swipe-back race,
335
+ // etc.) and end on a path the pair model didn't unwind, the landing screen
336
+ // can get stuck with temporary styles — the layer stays at -30% and shifts
337
+ // by 1/3, or the appBar root stays at opacity 0 (setPostExitPositions) and
338
+ // the whole app bar disappears.
339
+ //
340
+ // Guarantee: once everything settles (globalTransitionState === "idle") the
341
+ // top must always be in a clean default state. Wipe all leftover inline
342
+ // styles from the top activity — the behind is left untouched (stays at
343
+ // -30%), this only runs at idle, and it's a no-op if already clean.
344
+ //
345
+ // Keyed on the top activity too, not just globalTransitionState: an
346
+ // `animate: false` navigation makes the core skip the enter-active /
347
+ // exit-active phase entirely, so globalTransitionState never leaves "idle"
348
+ // and a state-only dependency would never re-run. That is how a behind
349
+ // layer became the top while still pinned at -30%.
350
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
351
+
352
+ // biome-ignore lint/correctness/useExhaustiveDependencies: topActivityId is a trigger, not a value read here — clearTopActivityStyles re-queries the DOM
353
+ useLayoutEffect(() => {
354
+ if (stack?.globalTransitionState !== "idle") return;
355
+ const stackEl = stackRef.current;
356
+ if (stackEl) {
357
+ clearTopActivityStyles(stackEl);
358
+ }
359
+ }, [stack?.globalTransitionState, topActivityId]);
360
+
329
361
  // Cancel any pending push rAF and running animations on unmount so
330
362
  // late-firing finished handlers can't run against a torn-down stack.
331
363
  useEffect(() => {