Skip to content

Commit ac51e42

Browse files
author
strausr
committed
fix: improve upload widget reliability and add video player poster options
- Add upload widget script to index.html (required, not just dynamic injection) - Simplify UploadWidget to use React onClick instead of manual event listeners - Add isReady state and loading indicator for better UX - Fix type import for CloudinaryUploadResult (use import type) - Add posterOptions to video player pattern (startOffset: '0', posterColor) - Tighten upload widget rules: always poll, add timeout, no single onload check - Fix plugin order inconsistency in rules - Fix corrupted text in rules file These changes address user feedback where upload widget would stop working after adding features like video player due to script load timing races.
1 parent ec6c885 commit ac51e42

File tree

4 files changed

+166
-76
lines changed

4 files changed

+166
-76
lines changed

templates/.cursorrules.template

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,15 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
4848
- Use **this** pattern for the reusable instance. Everywhere else: `import { cld } from './cloudinary/config'` (or the path the user chose) and call `cld.image(publicId)` / `cld.video(publicId)`.
4949

5050
**3. Upload Widget (unsigned, from scratch)**
51-
- **Script**: Add to `index.html`: `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>`. Because the script loads **async**, React's useEffect can run before it's ready — **do not** call `createUploadWidget` until the script is loaded.
52-
- **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval) or wait for the script's `onload` (if you inject the script in code). Otherwise you get "createUploadWidget is not a function".
53-
- **Create widget in useEffect**, not in render. Store the widget in a **ref**. Pass options: `{ cloudName: import.meta.env.VITE_CLOUDINARY_CLOUD_NAME, uploadPreset: uploadPreset || undefined, sources: ['local', 'camera', 'url'], multiple: false }`. Use `uploadPreset` from config or env.
54-
- **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup. Handle script load failures (e.g. show error state if script never loads).
51+
52+
**Strict pattern (always follow this exactly):**
53+
1. **Script in `index.html`** (required): Add `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>` to `index.html`. Do **not** rely only on dynamic script injection from React — it's fragile.
54+
2. **Poll in useEffect** (required): In `useEffect`, poll with `setInterval` (e.g. every 100ms) until `typeof window.cloudinary?.createUploadWidget === 'function'`. Only then create the widget. A single check (even in `onload`) is **not** reliable because `window.cloudinary` can exist before `createUploadWidget` is attached.
55+
3. **Add a timeout**: Set a timeout (e.g. 10 seconds) to stop polling and show an error if the script never loads. Clear both interval and timeout in cleanup.
56+
4. **Create widget once**: When `createUploadWidget` is available, create the widget and store it in a **ref**. Clear the interval and timeout. Pass options: `{ cloudName, uploadPreset, sources: ['local', 'camera', 'url'], multiple: false }`.
57+
5. **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup.
58+
59+
❌ **Do NOT**: Check only `window.cloudinary` (not enough); do a single check in `onload` (unreliable); skip the script in `index.html`; poll forever without a timeout.
5560
- **Signed uploads**: Do not use only `uploadPreset`; use the pattern under "Secure (Signed) Uploads" (uploadSignature as function, fetch api_key, server includes upload_preset in signature).
5661

5762
**4. Video player**
@@ -60,7 +65,7 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
6065
**5. Summary for rules-only users**
6166
- **Env**: Use your bundler's client env prefix and access (Vite: `VITE_` + `import.meta.env.VITE_*`; see "Other bundlers" if not Vite).
6267
- **Reusable instance**: One config file that creates and exports `cld` (and optionally `uploadPreset`) from `@cloudinary/url-gen`; use it everywhere.
63-
- **Upload widget**: Script in index.html (or equivalent); create widget once in useEffect with ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
68+
- **Upload widget**: Script in index.html (required); in useEffect, **poll** until `createUploadWidget` is a function, then create widget once and store in ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
6469
- **Video player**: Imperative video element (createElement, append to container ref, pass to videoPlayer); dispose + removeChild in cleanup; fall back to AdvancedVideo if init fails.
6570

6671
**If the user is not using Vite:** Use their bundler's client env prefix and access in the config file and everywhere you read env. Examples: Create React App → `REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`; Next.js (client) → `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`. The rest (cld instance, widget options, video player) is the same.
@@ -201,16 +206,16 @@ cld.image('id').overlay(
201206
- ✅ Import plugins from `@cloudinary/react`
202207
- ✅ Pass plugins as array: `plugins={[responsive(), lazyload(), placeholder()]}`
203208
- ✅ Recommended plugin order:
204-
1. `responsive()` - First
205-
2. `lazyload()` - Second
206-
3. `accessibility()` - Third
207-
4. `placeholder()` - Last
209+
1. `responsive()` - First (handles breakpoints)
210+
2. `placeholder()` - Second (shows placeholder while loading)
211+
3. `lazyload()` - Third (delays loading until in viewport)
212+
4. `accessibility()` - Last (if needed)
208213
- ✅ Always add `width` and `height` attributes to prevent layout shift
209214
- ✅ Example:
210215
```tsx
211216
<AdvancedImage
212217
cldImg={img}
213-
plugins={[responsive(), lazyload(), placeholder({ mode: 'blur' })]}
218+
plugins={[responsive(), placeholder({ mode: 'blur' }), lazyload()]}
214219
width={800}
215220
height={600}
216221
/>
@@ -250,11 +255,18 @@ cld.image('id').overlay(
250255

251256
## Upload Widget Pattern
252257
- ✅ Use component: `import { UploadWidget } from './cloudinary/UploadWidget'`
253-
- ✅ Load script in `index.html`:
258+
259+
**Strict initialization pattern (always follow this exactly):**
260+
1. ✅ **Script in `index.html`** (required):
254261
```html
255262
<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
256263
```
257-
- ✅ **Race condition**: The script loads **async**, so React's useEffect may run before `createUploadWidget` exists. **Wait until** `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (e.g. poll with setInterval or wait for script onload). Checking only `window.cloudinary` is not enough — `createUploadWidget` might not be attached yet. Otherwise: "createUploadWidget is not a function".
264+
2. ✅ **Poll in useEffect until `createUploadWidget` is available** (required): Use `setInterval` (e.g. every 100ms) to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
265+
3. ✅ **Add a timeout** (e.g. 10 seconds) to stop polling and show an error state if the script never loads. Clear both interval and timeout in cleanup and when ready.
266+
4. ✅ **Create widget once**, store in a ref. Cleanup: clear interval, clear timeout, remove click listener.
267+
268+
❌ **Do NOT**: Check only `window.cloudinary` (the function may not be attached yet); do a single check in `onload` (unreliable timing); skip `index.html` and rely only on dynamic injection; poll forever without a timeout.
269+
258270
- ✅ Create unsigned upload preset in dashboard at `settings/upload/presets`
259271
- ✅ Add to `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your_preset_name`
260272
- ✅ Handle callbacks:
@@ -413,6 +425,11 @@ Use when the user asks for a **video player** (styled UI, controls, playlists).
413425
- **Cleanup**: Call `player.dispose()`, then **only if** `el.parentNode` exists call `el.parentNode.removeChild(el)` (avoids NotFoundError).
414426
- **If init fails** (CSP, extensions, timing): render **AdvancedVideo** with the same publicId. Do not relax CSP in index.html or ask the user to disable extensions.
415427

428+
**Poster options**: Always include `posterOptions` for a predictable poster image with a fallback color:
429+
- `transformation: { startOffset: '0' }` — use the first frame of the video as the poster (consistent and loads reliably)
430+
- `posterColor: '#0f0f0f'` — if the poster image fails to load, shows a dark background instead of blank/broken
431+
- These can be overridden via props (e.g. `posterOptions={{ transformation: { startOffset: '5' } }}` for a different frame)
432+
416433
**Example (copy this pattern):**
417434
```tsx
418435
const containerRef = useRef<HTMLDivElement>(null);
@@ -423,7 +440,16 @@ useLayoutEffect(() => {
423440
el.className = 'cld-video-player cld-fluid';
424441
containerRef.current.appendChild(el);
425442
try {
426-
const player = videoPlayer(el, { cloudName, secure: true, controls: true, fluid: true });
443+
const player = videoPlayer(el, {
444+
cloudName,
445+
secure: true,
446+
controls: true,
447+
fluid: true,
448+
posterOptions: {
449+
transformation: { startOffset: '0' },
450+
posterColor: '#0f0f0f',
451+
},
452+
});
427453
player.source({ publicId: 'samples/elephants' });
428454
playerRef.current = player;
429455
} catch (err) { console.error(err); }
@@ -494,18 +520,28 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
494520
- ✅ Access with type safety: `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME`
495521

496522
### Type Guards and Safety
497-
- ✅ Type guard for window.cloudinary:
523+
- ✅ Type guard for window.cloudinary (check `createUploadWidget`, not just `cloudinary`):
498524
```tsx
499-
function isCloudinaryLoaded(): boolean {
525+
function isUploadWidgetReady(): boolean {
500526
return typeof window !== 'undefined' &&
501-
typeof window.cloudinary !== 'undefined';
527+
typeof window.cloudinary?.createUploadWidget === 'function';
502528
}
503529
```
504-
- ✅ Use type guards before accessing:
530+
- ✅ Use type guards before accessing (but **always poll with timeout** in useEffect — don't rely on a single check):
505531
```tsx
506-
if (isCloudinaryLoaded()) {
507-
window.cloudinary.createUploadWidget(...);
508-
}
532+
// In useEffect, poll until ready with timeout:
533+
const interval = setInterval(() => {
534+
if (isUploadWidgetReady()) {
535+
clearInterval(interval);
536+
clearTimeout(timeout);
537+
window.cloudinary.createUploadWidget(...);
538+
}
539+
}, 100);
540+
const timeout = setTimeout(() => {
541+
clearInterval(interval);
542+
console.error('Upload widget script failed to load');
543+
}, 10000);
544+
// Cleanup: clearInterval(interval); clearTimeout(timeout);
509545
```
510546

511547
### Ref Typing Patterns
@@ -553,7 +589,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
553589
- ✅ Use `placeholder()` and `lazyload()` plugins together
554590
- ✅ Always add `width` and `height` attributes to `AdvancedImage`
555591
- ✅ Store `public_id` from upload success, not full URL
556-
- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`
592+
- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`; always include `posterOptions` with `transformation: { startOffset: '0' }` and `posterColor: '#0f0f0f'` for reliable poster display
557593
- ✅ Use TypeScript for better autocomplete and error catching
558594
- ✅ Prefer `unknown` over `any` when types aren't available
559595
- ✅ Use type guards for runtime type checking
@@ -656,16 +692,17 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
656692
4. Consider using server-side upload for very large files
657693

658694
### Widget not opening
659-
- ❌ Problem: Script not loaded or initialization issue
695+
- ❌ Problem: Script not loaded, or widget created before `createUploadWidget` was available
660696
- ✅ Solution:
661697
1. Ensure script is in `index.html`: `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>`
662-
2. Check widget initializes in `useEffect` after `window.cloudinary` is available
698+
2. In `useEffect`, **poll** with `setInterval` until `typeof window.cloudinary?.createUploadWidget === 'function'` — only then create the widget. Do **not** check only `window.cloudinary`.
663699
3. Verify upload preset is set correctly
664700

665701
### "createUploadWidget is not a function"
666-
- ❌ Problem: **Race condition** — the script in index.html loads **async**, so React's useEffect can run before the script has finished loading. `window.cloudinary` might exist but `createUploadWidget` isn't attached yet.
667-
- ✅ **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval until it exists) or inject the script in code and call createUploadWidget in the script's `onload`. Don't assume `window.cloudinary` means the API is ready.
668-
- ✅ See PATTERNS → Upload Widget Pattern ("Race condition") and Project setup → Upload Widget ("Wait for script").
702+
- ❌ Problem: **Race condition** — the script loads **async**, so `window.cloudinary` can exist before `createUploadWidget` is attached. A single check (even in `onload`) is **not** reliable.
703+
- ✅ **Always poll**: In `useEffect`, use `setInterval` to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
704+
- ❌ **Do NOT**: Check only `window.cloudinary`; do a single check in `onload`; skip the script in `index.html`.
705+
- ✅ See PATTERNS → Upload Widget Pattern and Project setup → Upload Widget for the strict pattern.
669706

670707
### Video player: "Invalid target for null#on" or React removeChild or NotFoundError
671708
- ❌ Problem: Passing a React-managed `<video ref={...} />` to the player causes removeChild errors (the player mutates the DOM). Or container/ref not in DOM yet when init runs.
@@ -718,7 +755,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
718755

719756
### Cloudinary package install fails or "version doesn't exist"
720757
- ❌ Problem: Agent pinned a Cloudinary package to a specific version (e.g. `cloudinary-video-player@1.2.3`) that doesn't exist on npm, or used a wrong package name.
721-
- ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/tps://console.cloudinary.com/app/settings/upload/presets\n\n'-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
758+
- ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/url-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
722759

723760
### Confusion between AdvancedVideo and Video Player
724761
- **AdvancedVideo** = for **displaying** a video (not a full player). **Cloudinary Video Player** = the **player** (styled UI, controls, playlists, etc.).
@@ -742,6 +779,11 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
742779
### Video player: "source is not a function" or video not playing
743780
- **player.source()** takes an **object**: `player.source({ publicId: 'samples/elephants' })`, not a string. Use named import: `import { videoPlayer } from 'cloudinary-video-player'`. See PATTERNS → Cloudinary Video Player (The Player).
744781

782+
### Video player: poster image missing, wrong frame, or broken
783+
- ❌ Problem: Video player shows no poster, wrong poster frame, or blank area before video loads.
784+
- ✅ **Always include `posterOptions`** in the player config: `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }`. This uses the first frame as the poster (reliable) and provides a dark fallback color if the poster fails to load.
785+
- ✅ **Override if needed**: Pass different values via props, e.g. `startOffset: '5'` for a frame 5 seconds in, or a different `posterColor` for your design.
786+
745787
### Overlay: "Cannot read properties of undefined" or overlay not showing
746788
- ❌ Problem: Wrong overlay API usage (Overlay.source, compass constants, .transformation().resize, fontWeight on wrong object).
747789
- ✅ Import `source` directly from `@cloudinary/url-gen/actions/overlay` (not `Overlay.source`). Use **string** values for compass: `compass('south_east')` (underscores, not camelCase). Use `new Transformation()` inside `.transformation()`. Put `fontWeight` on **TextStyle**; put `textColor` on the **text source**. See PATTERNS → Image Overlays (text or logos).
@@ -815,8 +857,8 @@ When something isn't working, check:
815857
- [ ] Format/quality use separate `.delivery()` calls
816858
- [ ] Plugins are in array format
817859
- [ ] Upload widget script is loaded in `index.html`
818-
- [ ] **"createUploadWidget is not a function"?** → Wait until `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (script loads async; poll or use script onload)
819-
- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
860+
- [ ] **"createUploadWidget is not a function"?** → In useEffect, **poll** with setInterval until `typeof window.cloudinary?.createUploadWidget === 'function'`. Do NOT check only `window.cloudinary`; do NOT rely on a single onload check
861+
- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); include `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }` for reliable poster; player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
820862
- [ ] **Upload fails (unsigned)?** → Is `VITE_CLOUDINARY_UPLOAD_PRESET` set? Preset exists and is Unsigned in dashboard?
821863
- [ ] **Upload default?** → Default to **unsigned** uploads (cloudName + uploadPreset); use signed only when the user explicitly asks for secure/signed uploads (signed requires a running backend)
822864
- [ ] **Secure uploads?** → Use `uploadSignature` as function (not `signatureEndpoint`); fetch `api_key` from server first; include `uploadPreset` in widget config; server includes `upload_preset` in signed params; use Cloudinary Node SDK v2 on server; never expose or commit API secret

templates/index.html.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
</head>
99
<body>
1010
<div id="root"></div>
11+
<!-- Cloudinary upload widget - load early so it's ready before React; avoids races with other components -->
12+
<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
1113
<script type="module" src="/src/main.tsx"></script>
1214
</body>
1315
</html>

templates/src/App.tsx.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { auto as autoQuality } from '@cloudinary/url-gen/qualifiers/quality';
77
import { autoGravity } from '@cloudinary/url-gen/qualifiers/gravity';
88
import { cld } from './cloudinary/config';
99
import { UploadWidget } from './cloudinary/UploadWidget';
10+
import type { CloudinaryUploadResult } from './cloudinary/UploadWidget';
1011
import './App.css';
1112

1213
function App() {
1314
const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
1415

15-
const handleUploadSuccess = (result: any) => {
16+
const handleUploadSuccess = (result: CloudinaryUploadResult) => {
1617
console.log('Upload successful:', result);
1718
setUploadedImageId(result.public_id);
1819
};

0 commit comments

Comments
 (0)