Skip to content

Commit 40abf99

Browse files
authored
fix: sync web preview URL without iframe reloads (#394)
1 parent faa3664 commit 40abf99

File tree

6 files changed

+161
-105
lines changed

6 files changed

+161
-105
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@frontman/client": patch
3+
---
4+
5+
Fix web preview URL bar syncing so iframe link navigations update the displayed URL without forcing iframe reloads. The URL input is now editable and supports Enter-to-navigate while preserving in-iframe navigation state.

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ cd .worktrees/feature/my-feature
4141
- Story files: `*.story.res` (co-located with components)
4242
- Prefer `switch` over `if/else` — use pattern matching for control flow, even for simple boolean/option checks
4343

44+
## Raw JS vs ReScript
45+
46+
- Prefer ReScript/WebAPI bindings and typed externals over `%raw` JavaScript.
47+
- Use `%raw` only when there is no practical typed binding or the browser API cannot be expressed cleanly in ReScript.
48+
- Keep `%raw` blocks minimal and isolated to small interop boundaries; keep business logic and event handling in ReScript.
49+
- For DOM/browser events, prefer typed ReScript handlers plus small externals for missing fields instead of full raw listener implementations.
50+
4451
## Error Handling Philosophy
4552

4653
**Crash early and obviously. Never swallow exceptions.**

libs/client/src/Client__Hooks.res

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -276,94 +276,71 @@ module Scroll = {
276276
}
277277
}
278278

279-
// Helper to safely get iframe contentWindow - returns None for cross-origin iframes
280-
// Cross-origin iframes throw SecurityError when accessing contentWindow properties
281-
let getIframeWindowSafe: WebAPI.DOMAPI.element => option<WebAPI.DOMAPI.window> = %raw(`
282-
function(iframe) {
283-
try {
284-
var win = iframe.contentWindow;
285-
// Verify we have access by reading location (throws if cross-origin)
286-
if (win && win.location && win.location.href) {
287-
return win;
288-
}
289-
return undefined;
290-
} catch (e) {
291-
// Expected for cross-origin iframes - log for debugging
292-
console.debug('[useIFrameLocation] Cross-origin iframe access denied:', e.message);
293-
return undefined;
279+
module NavigateEvent = {
280+
type destination
281+
type t
282+
283+
@get external destination: t => destination = "destination"
284+
@get external url: destination => string = "url"
285+
}
286+
287+
let getIframeWindowSafe = (iframe: WebAPI.DOMAPI.element): option<WebAPI.DOMAPI.window> => {
288+
let iframeElement = iframe->Obj.magic
289+
try {
290+
switch WebAPI.HTMLIFrameElement.contentWindow(iframeElement)->Null.toOption {
291+
| None => None
292+
| Some(iframeWindow) =>
293+
ignore(iframeWindow->WebAPI.Window.location->WebAPI.Location.href)
294+
Some(iframeWindow)
294295
}
296+
} catch {
297+
| _ => None
295298
}
296-
`)
299+
}
297300

298-
let useIFrameLocation = (~iframeRef: Nullable.t<WebAPI.DOMAPI.element>) => {
301+
let useIFrameLocation = (~iframeElement: option<WebAPI.DOMAPI.element>, ~attachmentKey: int) => {
299302
let (location, setLocation) = React.useState(() => None)
300303

301304
React.useEffect(() => {
302-
let iframeWindow =
303-
iframeRef
304-
->Nullable.toOption
305-
->Option.flatMap(iframe => getIframeWindowSafe(iframe))
306-
307-
switch iframeWindow {
308-
| Some(iframeWindow) =>
309-
// Get initial location (safe since getIframeWindowSafe verified access)
310-
let initialLocation = Some(iframeWindow->WebAPI.Window.location->WebAPI.Location.href)
311-
setLocation(_ => initialLocation)
312-
313-
// Listen for navigation events
314-
let onPopState = _ev => {
315-
let currentLocation = Some(iframeWindow->WebAPI.Window.location->WebAPI.Location.href)
316-
setLocation(_ => currentLocation)
317-
}
318-
let onNavigation = ev => {
319-
let url = ev["destination"]["url"]
320-
let currentLocation = Some(url)
321-
setLocation(_ => currentLocation)
322-
}
323-
324-
// Check if Navigation API is supported (not available in Firefox/Safari)
325-
let navigationSupported = %raw(`typeof iframeWindow.navigation !== 'undefined'`)
326-
327-
WebAPI.Window.addEventListener(
328-
iframeWindow,
329-
Custom("popstate"),
330-
onPopState,
331-
~options={capture: false},
332-
)
305+
switch iframeElement {
306+
| None =>
307+
setLocation(_ => None)
308+
None
309+
| Some(iframe) =>
310+
switch getIframeWindowSafe(iframe) {
311+
| None =>
312+
setLocation(_ => None)
313+
None
314+
| Some(iframeWindow) =>
315+
let initialLocation = Some(iframeWindow->WebAPI.Window.location->WebAPI.Location.href)
316+
setLocation(_ => initialLocation)
317+
318+
let onNavigation = (ev: WebAPI.EventAPI.event) => {
319+
let navigateEvent: NavigateEvent.t = ev->Obj.magic
320+
let destinationUrl = navigateEvent->NavigateEvent.destination->NavigateEvent.url
321+
setLocation(_ => Some(destinationUrl))
322+
}
333323

334-
// Only use Navigation API if supported
335-
if navigationSupported {
336324
WebAPI.Navigation.addEventListener(
337325
iframeWindow.navigation,
338326
Custom("navigate"),
339327
onNavigation,
340328
~options={capture: false},
341329
)
342-
}
343-
344-
Some(
345-
() => {
346-
WebAPI.Window.removeEventListener(
347-
iframeWindow,
348-
Custom("popstate"),
349-
onPopState,
350-
~options={capture: false},
351-
)
352330

353-
// Only remove Navigation API listener if it was added
354-
if navigationSupported {
331+
Some(
332+
() => {
355333
WebAPI.Navigation.removeEventListener(
356334
iframeWindow.navigation,
357335
Custom("navigate"),
358336
onNavigation,
359337
~options={capture: false},
360338
)
361-
}
362-
},
363-
)
364-
| None => None
339+
},
340+
)
341+
}
365342
}
366-
}, (iframeRef, setLocation))
343+
}, (iframeElement, attachmentKey))
367344

368345
location
369346
}

libs/client/src/webpreview/Client__WebPreview.res

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
module Nav = Client__WebPreview__Nav
88
module RadixUI__Icons = Bindings__RadixUI__Icons
99

10+
@send external locationAssign: ('a, string) => unit = "assign"
11+
@send external blur: (Dom.element) => unit = "blur"
12+
1013
module BackButton = {
1114
@react.component
1215
let make = (~onClick: unit => unit) => {
@@ -128,7 +131,6 @@ let useContainerSize = (ref: React.ref<Nullable.t<Dom.element>>): (int, int) =>
128131

129132
@react.component
130133
let make = () => {
131-
// Use primitive selectors for efficient comparison (strings compare by value)
132134
let currentTaskClientId = Client__State.useSelector(Client__State.Selectors.currentTaskClientId)
133135
let isNewTask = Client__State.useSelector(Client__State.Selectors.isNewTask)
134136
let persistedTasks = Client__State.useSelector(Client__State.Selectors.tasks)
@@ -143,12 +145,51 @@ let make = () => {
143145
let containerRef: React.ref<Nullable.t<Dom.element>> = React.useRef(Nullable.null)
144146
let (availableWidth, availableHeight) = useContainerSize(containerRef)
145147

146-
// Persist device mode changes to localStorage
147148
React.useEffect(() => {
148149
Client__DeviceMode.persist(deviceMode, deviceOrientation)
149150
None
150151
}, (deviceMode, deviceOrientation))
151152

153+
let (editableUrl, setEditableUrl) = React.useState(() => previewUrl)
154+
let (isEditingUrl, setIsEditingUrl) = React.useState(() => false)
155+
156+
let displayedUrl = switch isEditingUrl {
157+
| true => editableUrl
158+
| false => previewUrl
159+
}
160+
161+
let handleUrlChange = (e: ReactEvent.Form.t) => {
162+
let value = (e->ReactEvent.Form.target)["value"]
163+
setEditableUrl(_ => value)
164+
}
165+
166+
let handleUrlKeyDown = (e: ReactEvent.Keyboard.t) => {
167+
switch ReactEvent.Keyboard.key(e) {
168+
| "Enter" =>
169+
let url = editableUrl
170+
previewFrame.contentWindow->Option.forEach(contentWindow => {
171+
contentWindow.location->locationAssign(url)
172+
})
173+
Client__State.Actions.setPreviewUrl(~url)
174+
Client__State.Actions.setSelectedElement(~selectedElement=None)
175+
let target: Dom.element = ReactEvent.Keyboard.target(e)->Obj.magic
176+
target->blur
177+
| "Escape" =>
178+
let target: Dom.element = ReactEvent.Keyboard.target(e)->Obj.magic
179+
target->blur
180+
| _ => ()
181+
}
182+
}
183+
184+
let handleUrlFocus = (_e: ReactEvent.Focus.t) => {
185+
setIsEditingUrl(_ => true)
186+
setEditableUrl(_ => previewUrl)
187+
}
188+
189+
let handleUrlBlur = (_e: ReactEvent.Focus.t) => {
190+
setIsEditingUrl(_ => false)
191+
}
192+
152193
let handleBack = () => {
153194
previewFrame.contentWindow->Option.forEach(contentWindow => {
154195
WebAPI.History.back(contentWindow.history)
@@ -182,20 +223,25 @@ let make = () => {
182223

183224
let deviceModeActive = Client__DeviceMode.isActive(deviceMode)
184225
let effectiveDims = Client__DeviceMode.getEffectiveDimensions(deviceMode, deviceOrientation)
185-
226+
186227
<Nav.Container>
187228
<Nav.Navigation>
188229
<Nav.TrafficLights />
189230
<BackButton onClick={handleBack} />
190231
<ForwardButton onClick={handleForward} />
191232
<ReloadButton onClick={handleReload} />
192-
<Nav.UrlInput value={previewUrl} />
233+
<Nav.UrlInput
234+
value={displayedUrl}
235+
onChange={handleUrlChange}
236+
onKeyDown={handleUrlKeyDown}
237+
onFocus={handleUrlFocus}
238+
onBlur={handleUrlBlur}
239+
/>
193240
<DeviceModeToggle isActive={deviceModeActive} onClick={handleToggleDeviceMode} />
194241
<SelectElement onClick={handleSelect} isSelecting={webPreviewIsSelecting} />
195242
<OpenInNewWindow onClick={handleOpenInNewTab} />
196243
</Nav.Navigation>
197244

198-
// Device bar (only when device mode is active)
199245
<Client__WebPreview__DeviceBar deviceMode orientation=deviceOrientation />
200246

201247
<div

0 commit comments

Comments
 (0)