Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/core/src/app/checkout/CheckoutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type CheckoutStepStatus from './CheckoutStepStatus';
import CheckoutStepType from './CheckoutStepType';
import type CheckoutSupport from './CheckoutSupport';
import mapToCheckoutProps from './mapToCheckoutProps';
import { type CheckoutRefreshAPI, createCheckoutRefreshAPI, createPrerenderingChangeHandler, PrerenderingStalenessDetector } from './prerenderingStalenessDetector';

const Billing = lazy(() =>
retry(
Expand Down Expand Up @@ -181,12 +182,22 @@ class Checkout extends Component<

private embeddedMessenger?: EmbeddedCheckoutMessenger;
private unsubscribeFromConsignments?: () => void;
private prerenderingStalenessDetector = new PrerenderingStalenessDetector();
private checkoutRefreshAPI?: CheckoutRefreshAPI;

componentWillUnmount(): void {
if (this.unsubscribeFromConsignments) {
this.unsubscribeFromConsignments();
this.unsubscribeFromConsignments = undefined;
}

// Clean up prerendering staleness detector
this.prerenderingStalenessDetector.reset();

// Clean up global API
if (typeof window !== 'undefined' && (window as any).checkoutRefreshAPI === this.checkoutRefreshAPI) {
delete (window as any).checkoutRefreshAPI;
}

window.removeEventListener('beforeunload', this.handleBeforeExit);
this.handleBeforeExit();
Expand All @@ -195,11 +206,13 @@ class Checkout extends Component<
async componentDidMount(): Promise<void> {
const {
analyticsTracker,
checkoutId,
containerId,
createEmbeddedMessenger,
data,
embeddedStylesheet,
extensionService,
loadCheckout,
loadPaymentMethodByIds,
subscribeToConsignments,
} = this.props;
Expand Down Expand Up @@ -248,14 +261,47 @@ class Checkout extends Component<
messenger.postLoaded();

if (document.prerendering) {
document.addEventListener('prerenderingchange', () => {
// Capture initial snapshot when page is prerendered
this.prerenderingStalenessDetector.captureInitialSnapshot(data);

// Set up enhanced prerenderingchange handler
const prerenderingChangeHandler = createPrerenderingChangeHandler(
this.prerenderingStalenessDetector,
loadCheckout,
() => data,
checkoutId,
(wasStale) => {
// Log for debugging - this could be removed in production
if (wasStale) {
// eslint-disable-next-line no-console
console.debug('Checkout data was refreshed due to staleness after prerendering');
}
}
);

document.addEventListener('prerenderingchange', async () => {
analyticsTracker.checkoutBegin();
// Refresh checkout data in background if needed
await prerenderingChangeHandler();
}, { once: true });
}
else {
analyticsTracker.checkoutBegin();
}

// Set up global checkout refresh API
this.checkoutRefreshAPI = createCheckoutRefreshAPI(
this.prerenderingStalenessDetector,
loadCheckout,
() => data,
checkoutId
);

// Expose API globally for external use
if (typeof window !== 'undefined') {
(window as any).checkoutRefreshAPI = this.checkoutRefreshAPI;
}

const consignments = data.getConsignments();
const cart = data.getCart();

Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/app/checkout/PRERENDERING_STALENESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Prerendering Staleness Detection

This document describes the implementation of staleness detection for prerendered checkout pages.

## Overview

When using prerendering with Speculation Rules, checkout pages may become stale if the cart state changes after prerendering but before the user navigates to the checkout. This implementation automatically detects and refreshes stale checkout data without causing jank for users.

## Implementation

### Core Components

1. **PrerenderingStalenessDetector**: Captures and compares checkout snapshots
2. **Prerendering Change Handler**: Handles the `prerenderingchange` event
3. **Global Refresh API**: Exposed on `window.checkoutRefreshAPI` for external use

### Detection Strategy

The system uses a simple and efficient version-based approach to detect staleness:

- **Checkout ID**: Unique identifier for the checkout session
- **Version Number**: Integer version field from the `/api/storefront/checkouts/[id]` API response that increments whenever cart contents, totals, or other checkout data changes

This approach is much simpler and more reliable than comparing multiple individual fields, as the version number automatically captures any meaningful change to the checkout state.

### Usage

#### Automatic Detection (Built-in)

The implementation automatically:

1. Captures initial snapshot when a page is prerendered
2. Listens for the `prerenderingchange` event
3. Refreshes checkout data in the background if changes are detected
4. Logs when refresh occurs due to staleness

#### Manual Refresh API

The global API is available at `window.checkoutRefreshAPI`:

```typescript
// Check if checkout data is stale
const isStale = window.checkoutRefreshAPI?.isCheckoutStale();

// Force refresh checkout data
const result = await window.checkoutRefreshAPI?.refreshCheckout(true);
console.log('Refresh success:', result.success, 'Was stale:', result.wasStale);

// Get current snapshot for debugging
const snapshot = window.checkoutRefreshAPI?.getCurrentSnapshot();
console.log('Current checkout state:', snapshot);
// Example output: { checkoutId: "abc123", version: 2, timestamp: 1234567890 }
```

#### External Integration Examples

```javascript
// Example: Refresh when cart is modified in another tab
window.addEventListener('storage', async (e) => {
if (e.key === 'cart_modified') {
const result = await window.checkoutRefreshAPI?.refreshCheckout();
if (result?.wasStale) {
console.log('Checkout was refreshed due to cart changes');
}
}
});

// Example: Periodic staleness check
setInterval(async () => {
if (window.checkoutRefreshAPI?.isCheckoutStale()) {
await window.checkoutRefreshAPI.refreshCheckout();
}
}, 30000); // Check every 30 seconds
```

## Files Modified

- `packages/core/src/app/checkout/prerenderingStalenessDetector.ts` - Core implementation with version-based detection
- `packages/core/src/app/checkout/CheckoutPage.tsx` - Integration with checkout page
- `packages/core/types/dom.extended.d.ts` - Type definitions for global API
- `packages/**/src/**/*.mock.ts` - Updated checkout mocks to include version property
- `webpack.config.js` - Fixed MANIFEST_JSON race condition for development mode

## Benefits

1. **Seamless UX**: No jank when checkout data hasn't changed
2. **Automatic Updates**: Stale data is refreshed transparently using version-based detection
3. **Efficient Detection**: Simple integer comparison (version numbers) instead of complex field-by-field comparison
4. **External API**: Allows custom refresh triggers from external code
5. **Debugging Support**: Comprehensive logging and inspection capabilities
6. **Type Safety**: Full TypeScript support with module augmentation for the version field

## Edge Cases Handled

- Missing cart/checkout data
- Network failures during refresh (graceful degradation)
- Multiple rapid refresh attempts
- Component unmounting during refresh
- API cleanup on page navigation

## Testing

Comprehensive test coverage includes:
- Staleness detection accuracy
- Error handling
- API functionality
- Integration with existing prerendering flow

Run tests with:
```bash
npx nx run core:test --testPathPattern=prerenderingStalenessDetector
```

All tests focus on version-based detection logic and API functionality. The test suite includes 18 tests covering the detector class, event handling, and global API methods.
Loading