Skip to content

Commit 1248891

Browse files
ydanivameerf-wix
andauthored
Fix entrance FOUC (#277)
* Add plan for fixing entrance FOUC mechanism * Fix format * Change fouc prevention mechanism and add fill backwards to entrance * Format and cleanup * Fixed PR comments * Fix phrasing of fill in viewenter rules * Update packages/interact-validate/src/semantic/recommendedPatterns.ts Co-authored-by: Ameer Abu-Fraiha <ameerf@wix.com> * Simplified recommendation for fill on entrance * Fix tests --------- Co-authored-by: Ameer Abu-Fraiha <ameerf@wix.com>
1 parent 2542dfe commit 1248891

45 files changed

Lines changed: 510 additions & 78 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
name: Fix entrance FOUC
3+
overview: Prevent delayed entrance flashes without changing IntersectionObserver geometry by making the transient neutral styles author-important, using real zero delays, and giving entrance effects backwards fill by default. Align validation, documentation, and Interactor guidance with the runtime behavior.
4+
todos:
5+
- id: important-initial-css
6+
content: Emit author-important transient FOUC declarations and preserve true zero animation delays
7+
status: completed
8+
- id: entrance-fill-defaults
9+
content: Default every entrance preset descriptor to backwards fill while preserving explicit overrides
10+
status: completed
11+
- id: validation-guidance
12+
content: Add backwards-fill validation guidance and align Interact docs, rules, and Interactor skill
13+
status: completed
14+
- id: regression-tests
15+
content: Cover CSS serialization, timing, all entrance presets, validator behavior, and run targeted verification
16+
status: completed
17+
isProject: false
18+
---
19+
20+
# Fix Delayed Entrance FOUC
21+
22+
## Implementation
23+
24+
- Extend the internal declaration shape in [`packages/interact/src/types/css.ts`](packages/interact/src/types/css.ts) with an `important` flag and serialize it in [`packages/interact/src/core/cssUtils.ts`](packages/interact/src/core/cssUtils.ts). Mark every [`DEFAULT_INITIAL`](packages/interact/src/core/css.ts) declaration important so `visibility: hidden` and neutral transform properties override pre-trigger animation values, while retaining the existing `:not([data-interact-enter])` lifecycle.
25+
- In [`packages/motion/src/api/cssAnimations.ts`](packages/motion/src/api/cssAnimations.ts), replace the synthetic `delay || 1` fallback with `delay ?? 0`. Do not add the proposed `0.1ms` initial override, because it would alter computed sequence timing and could move a playing animation back from its active phase into its delay phase.
26+
- Add a small `getEntranceFill()` helper in [`packages/motion-presets/src/utils.ts`](packages/motion-presets/src/utils.ts), returning `options.fill ?? 'backwards'`. Apply it after `...options` to every animation descriptor in all 19 modules under [`packages/motion-presets/src/library/entrance/`](packages/motion-presets/src/library/entrance/) so both CSS and WAAPI paths default to backwards fill while explicit `none`, `forwards`, or `both` values remain authoritative.
27+
28+
## Validation and guidance
29+
30+
- Add an informational `RECOMMENDED_FILL_BACKWARDS` semantic check in [`packages/interact-validate/src/semantic/recommendedPatterns.ts`](packages/interact-validate/src/semantic/recommendedPatterns.ts), wire it through [`collectSemanticWarnings.ts`](packages/interact-validate/src/semantic/collectSemanticWarnings.ts) and [`errors.ts`](packages/interact-validate/src/errors.ts), and recommend explicit `backwards` or `both` for effective `viewEnter`/`once` animation effects, including nested sequence effects. Keep this advisory so preset defaults remain a safety net rather than a schema requirement.
31+
- Update the authoritative entrance/FOUC guidance and generated CSS examples in [`packages/interact/rules/viewenter.md`](packages/interact/rules/viewenter.md), [`packages/interact/rules/full-lean.md`](packages/interact/rules/full-lean.md), [`packages/interact/docs/api/functions.md`](packages/interact/docs/api/functions.md), and [`packages/interact/docs/examples/entrance-animations.md`](packages/interact/docs/examples/entrance-animations.md): explain the important neutral pre-trigger rule, use `fill: 'backwards'` for `once`, and reserve `both` for effects that must retain their final keyframe.
32+
- Update the repository Interactor source in [`skills/interactor/SKILL.md`](skills/interactor/SKILL.md) plus its `config-schema`, `triggers`, `integration-recipes`, and `validate` references. Make explicit backwards fill an invariant in generated `viewEnter`/`once` configs and update the relevant eval expectation; do not manually edit external installed skill snapshots.
33+
34+
## Regression coverage
35+
36+
- Update [`packages/interact/test/css.spec.ts`](packages/interact/test/css.spec.ts) and [`cssUtils.spec.ts`](packages/interact/test/cssUtils.spec.ts) to verify important initial declarations are emitted only for eligible same-element `viewEnter`/`once` effects and disappear from the post-start selector.
37+
- Update [`packages/motion/test/motion.spec.ts`](packages/motion/test/motion.spec.ts) to assert omitted/zero delay produces `0ms`, while positive delays remain unchanged.
38+
- Add centralized entrance-preset tests covering all 19 exports: every generated descriptor defaults to `backwards`, and an explicit fill value is preserved. Retain existing per-preset behavior tests and add coverage for the currently untested `ExpandIn` export through this matrix.
39+
- Add validator tests for direct effects, referenced registry effects, sequence effects, implicit `once`, accepted `backwards`/`both`, and informational severity/category overrides.
40+
41+
## Verification
42+
43+
- Run `nvm use`, then targeted tests and type checks for `@wix/motion`, `@wix/motion-presets`, `@wix/interact`, and `@wix/interact-validate`; finish with repository lint/format checks for touched files.

packages/interact-validate/README.md

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -194,25 +194,26 @@ The single source of truth for every code the validator emits. The agent-facing
194194

195195
These encode statically-detectable authoring pitfalls from the trigger rule files. Each belongs to a [rule category](#severity-model), so set the category to `'off'` to silence it or `'error'` to make it fail `valid`.
196196

197-
| Code | Trigger | Rule category |
198-
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------ |
199-
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
200-
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
201-
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
202-
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
203-
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
204-
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
205-
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
206-
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
207-
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
208-
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
209-
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
210-
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
211-
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
212-
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
213-
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
214-
| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` |
215-
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
197+
| Code | Trigger | Rule category |
198+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
199+
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
200+
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
201+
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
202+
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
203+
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
204+
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
205+
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
206+
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
207+
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
208+
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
209+
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
210+
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
211+
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
212+
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
213+
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
214+
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
215+
| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` |
216+
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
216217

217218
## Usage recipes
218219

packages/interact-validate/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const RULE_CODE_MAP: Record<string, string> = {
2929
EMPTY_STYLE_PROPERTIES: 'STATE_EFFECT',
3030
STATE_REMOVE_WITHOUT_EFFECT_ID: 'STATE_EFFECT',
3131
RECOMMENDED_FILL_BOTH: 'RECOMMENDED_FILL',
32+
RECOMMENDED_FILL_BACKWARDS: 'RECOMMENDED_FILL',
3233
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
3334
KEYFRAME_PROP_NOT_CAMEL_CASE: 'KEYFRAME_STYLE',
3435
INVALID_INSET: 'VIEW_INSET',

packages/interact-validate/src/semantic/fouc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { RETRIGGER_TYPES } from '../types';
1111
const DISCRETE_TRIGGERS = ['hover', 'click', 'interest', 'activate'];
1212
const HIT_AREA_TRANSFORM = /(translate|scale|matrix)/;
1313

14-
function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean {
14+
export function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean {
1515
if (effect.key !== undefined && effect.key !== owner.key) return false;
1616
const refiners = ['selector', 'listContainer', 'listItemSelector'] as const;
1717
for (const field of refiners) {

packages/interact-validate/src/semantic/recommendedPatterns.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';
22
import { RETRIGGER_TYPES } from '../types';
33

4-
// recommended `fill: 'both'` for scrubbed and toggling effects
4+
function isKeyframeEffect(effect: AnyEffect): boolean {
5+
return !!(effect.namedEffect || effect.keyframeEffect);
6+
}
7+
8+
// recommended fill for scrubbed, toggling, and viewEnter entrance effects
59
export function checkRecommendedFill(
610
path: Path,
711
effect: AnyEffect,
@@ -11,16 +15,34 @@ export function checkRecommendedFill(
1115
const isScrubbed = owner?.trigger === 'viewProgress' || owner?.trigger === 'pointerMove';
1216
const isToggling =
1317
effect.triggerType !== undefined && RETRIGGER_TYPES.includes(effect.triggerType);
14-
if (!isScrubbed && !isToggling) return [];
15-
const reason = isScrubbed
16-
? `${owner?.trigger} (scrubbed) effects`
17-
: `triggerType '${effect.triggerType}' effects`;
18+
19+
if (isScrubbed || isToggling) {
20+
const reason = isScrubbed
21+
? `${owner?.trigger} (scrubbed) effects`
22+
: `triggerType '${effect.triggerType}' effects`;
23+
return [
24+
{
25+
code: 'custom',
26+
params: { domainCode: 'RECOMMENDED_FILL_BOTH' },
27+
path: [...path, 'fill'],
28+
message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`,
29+
severity: 'info',
30+
},
31+
];
32+
}
33+
34+
if (!owner || owner.trigger !== 'viewEnter') return [];
35+
const triggerType = effect.triggerType ?? 'once';
36+
if (triggerType !== 'once') return [];
37+
if (!isKeyframeEffect(effect)) return [];
38+
if (effect.fill === 'backwards' || effect.fill === 'both') return [];
39+
1840
return [
1941
{
2042
code: 'custom',
21-
params: { domainCode: 'RECOMMENDED_FILL_BOTH' },
43+
params: { domainCode: 'RECOMMENDED_FILL_BACKWARDS' },
2244
path: [...path, 'fill'],
23-
message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`,
45+
message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) so the starting keyframe applies before the animation starts on a target without FOUC hiding rules.`,
2446
severity: 'info',
2547
},
2648
];

packages/interact-validate/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type AnyEffect = {
4040
triggerType?: string;
4141
stateAction?: string;
4242
fill?: string;
43+
delay?: number;
4344
namedEffect?: { type?: string; range?: unknown; [k: string]: unknown };
4445
keyframeEffect?: { name?: string; keyframes?: Array<Record<string, unknown>> };
4546
transition?: { styleProperties?: unknown[] };

0 commit comments

Comments
 (0)