From 8ab78f662ddeca26b19fd1f80a57e3d49e681a6e Mon Sep 17 00:00:00 2001 From: "David J. Bradshaw" Date: Sat, 9 May 2026 18:27:46 +0100 Subject: [PATCH 01/12] Allow option update --- e2e/tests/shared/parent-methods.js | 23 ++++ packages/angular/directive.ts | 38 ++++-- packages/child/events/mouse.test.ts | 12 ++ packages/child/events/mouse.ts | 5 +- packages/child/page/links.branches.test.ts | 1 + packages/child/page/links.test.ts | 11 +- packages/child/page/links.ts | 22 ++-- packages/child/page/links.units.test.ts | 2 +- packages/child/received/process-request.ts | 2 + packages/child/received/update.test.ts | 124 ++++++++++++++++++ packages/child/received/update.ts | 50 +++++++ packages/common/consts.ts | 1 + .../core/checks/min-child-version.test.ts | 45 +++++++ packages/core/checks/min-child-version.ts | 10 ++ packages/core/index.test.ts | 10 +- packages/core/index.ts | 6 +- packages/core/setup/update.test.ts | 80 +++++++++++ packages/core/setup/update.ts | 47 +++++++ packages/react/index.test.tsx | 43 ++++++ packages/react/index.tsx | 19 ++- packages/react/options-key.ts | 43 ++++++ packages/solid/IframeResizer.tsx | 52 +++----- packages/solid/wire-options.ts | 25 ++++ packages/svelte/IframeResizer.svelte | 37 +++++- packages/vue/iframe-resizer.vue | 26 +++- packages/web-component/index.ts | 81 ++++++++---- tsconfig.json | 2 + vite.config/solid.post-build.js | 18 +-- 28 files changed, 726 insertions(+), 109 deletions(-) create mode 100644 packages/child/received/update.test.ts create mode 100644 packages/child/received/update.ts create mode 100644 packages/core/checks/min-child-version.test.ts create mode 100644 packages/core/checks/min-child-version.ts create mode 100644 packages/core/setup/update.test.ts create mode 100644 packages/core/setup/update.ts create mode 100644 packages/react/options-key.ts create mode 100644 packages/solid/wire-options.ts diff --git a/e2e/tests/shared/parent-methods.js b/e2e/tests/shared/parent-methods.js index e2f15f820..7e3cabf45 100644 --- a/e2e/tests/shared/parent-methods.js +++ b/e2e/tests/shared/parent-methods.js @@ -50,6 +50,29 @@ export function parentMethodTests(baseUrl, { hasDisconnect = true } = {}) { ) }) + test('connectResizer re-binding sends update without breaking the iframe', async ({ + page, + }) => { + await page.goto(baseUrl) + await page.waitForLoadState('networkidle') + await waitForResizer(page) + + // Skip frameworks that don't expose the imperative factory globally. + const hasFactory = await page.evaluate( + () => typeof window.iframeResize === 'function', + ) + test.skip(!hasFactory, 'iframeResize factory not exposed globally') + + // Re-bind with new options on an already-connected iframe. + // The update flow should fire (no throw) and iframeResizer stays attached. + const result = await page.evaluate(() => { + const iframe = document.querySelector('iframe') + window.iframeResize({ license: 'GPLv3', log: true }, iframe) + return iframe.iframeResizer ? 'attached' : 'detached' + }) + expect(result).toBe('attached') + }) + if (hasDisconnect) { test('disconnect removes iframeResizer from iframe', async ({ page }) => { await page.goto(baseUrl) diff --git a/packages/angular/directive.ts b/packages/angular/directive.ts index 4bbbecb8d..053799b8d 100644 --- a/packages/angular/directive.ts +++ b/packages/angular/directive.ts @@ -8,6 +8,7 @@ import { EventEmitter, Input, Output, + type SimpleChanges, } from '@angular/core' import { esModuleInterop } from '@iframe-resizer/common' import type { @@ -63,16 +64,8 @@ export class IframeResizerDirective { constructor(private elementRef: ElementRef) {} - ngAfterViewInit(): void { - const id = this.elementRef.nativeElement?.id - - this.consoleGroup.label(`angular(${id})`) - this.consoleGroup.event('setup') - this.consoleGroup.expand(this.options.logExpand) - - if (this.debug) this.consoleGroup.log('ngAfterViewInit') - - this.resizer = connectResizer({ + private buildOptions(): IFrameOptions { + return { ...this.options, onBeforeClose: () => { @@ -98,7 +91,30 @@ export class IframeResizerDirective { top: number left: number }) => this.onScroll.next(event), - })(this.elementRef.nativeElement) + } as IFrameOptions + } + + ngAfterViewInit(): void { + const id = this.elementRef.nativeElement?.id + + this.consoleGroup.label(`angular(${id})`) + this.consoleGroup.event('setup') + this.consoleGroup.expand(this.options.logExpand) + + if (this.debug) this.consoleGroup.log('ngAfterViewInit') + + this.resizer = connectResizer(this.buildOptions())( + this.elementRef.nativeElement, + ) + } + + ngOnChanges(changes: SimpleChanges): void { + // Re-bind when @Input options change. Skip the first call: the binding + // hasn't been established yet at that point — ngAfterViewInit handles it. + if (!this.resizer) return + if (!changes.options) return + if (this.debug) this.consoleGroup.log('ngOnChanges: options updated') + connectResizer(this.buildOptions())(this.elementRef.nativeElement) } ngOnDestroy(): void { diff --git a/packages/child/events/mouse.test.ts b/packages/child/events/mouse.test.ts index 0d12fab10..30741d4da 100644 --- a/packages/child/events/mouse.test.ts +++ b/packages/child/events/mouse.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import sendMessage from '../send/message' +import settings from '../values/settings' import setupMouseEvents from './mouse' vi.mock('../send/message', () => ({ default: vi.fn() })) @@ -8,6 +9,7 @@ vi.mock('../send/message', () => ({ default: vi.fn() })) describe('child/events/mouse', () => { let addSpy beforeEach(() => { + settings.mouseEvents = true addSpy = vi.spyOn(document, 'addEventListener') }) @@ -37,4 +39,14 @@ describe('child/events/mouse', () => { expect(sendMessage).toHaveBeenCalledWith(0, 0, 'mouseenter', '10:20') expect(sendMessage).toHaveBeenCalledWith(0, 0, 'mouseleave', '30:40') }) + + test('does not forward events when settings.mouseEvents is false', () => { + setupMouseEvents({ mouseEvents: true }) + + settings.mouseEvents = false + const [, fn1] = addSpy.mock.calls[0].slice(0, 2) + fn1({ type: 'mouseenter', screenY: 10, screenX: 20 }) + + expect(sendMessage).not.toHaveBeenCalled() + }) }) diff --git a/packages/child/events/mouse.ts b/packages/child/events/mouse.ts index 5ce818383..31bbd156e 100644 --- a/packages/child/events/mouse.ts +++ b/packages/child/events/mouse.ts @@ -3,10 +3,13 @@ import { HIGHLIGHT } from 'auto-console-group' import { log } from '../console' import sendMessage from '../send/message' +import settings from '../values/settings' import { addEventListener } from './listeners' -const sendMouse = (evt: MouseEvent): void => +const sendMouse = (evt: MouseEvent): void => { + if (settings.mouseEvents !== true) return sendMessage(0, 0, evt.type, `${evt.screenY}:${evt.screenX}`) +} function addMouseListener(evt: string, name: string): void { log(`Add event listener: %c${name}`, HIGHLIGHT) diff --git a/packages/child/page/links.branches.test.ts b/packages/child/page/links.branches.test.ts index 5b74bd6d6..08effd8e8 100644 --- a/packages/child/page/links.branches.test.ts +++ b/packages/child/page/links.branches.test.ts @@ -18,6 +18,7 @@ describe('child/page/links branches', () => { document.body.innerHTML = '' state.inPageLinks = undefined settings.mode = 0 + settings.inPageLinks = true sendMessage.mockClear() }) diff --git a/packages/child/page/links.test.ts b/packages/child/page/links.test.ts index 92f626a6f..9a3a4c6fa 100644 --- a/packages/child/page/links.test.ts +++ b/packages/child/page/links.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import sendMessage from '../send/message' import settings from '../values/settings' import state from '../values/state' -import setupInPageLinks from './links' +import setupInPageLinks, { findTarget } from './links' vi.mock('../console', () => ({ log: vi.fn(), advise: vi.fn() })) vi.mock('../send/message', () => ({ __esModule: true, default: vi.fn() })) @@ -13,6 +13,8 @@ describe('child/page/links', () => { vi.clearAllMocks() document.body.innerHTML = '' settings.mode = 0 + settings.inPageLinks = true + state.inPageLinks = undefined }) test('setup and findTarget sends message for existing id', () => { @@ -30,4 +32,11 @@ describe('child/page/links', () => { expect(sendMessage).toHaveBeenCalled() }) + + test('findTarget no-op when inPageLinks setting becomes false', () => { + settings.inPageLinks = false + findTarget('#nope') + + expect(sendMessage).not.toHaveBeenCalled() + }) }) diff --git a/packages/child/page/links.ts b/packages/child/page/links.ts index a29421f68..1812257f0 100644 --- a/packages/child/page/links.ts +++ b/packages/child/page/links.ts @@ -43,6 +43,8 @@ function jumpToTarget(hash: string, target: Element): void { } export function findTarget(location: string): void { + if (settings.inPageLinks !== true) return + const hash = location.split('#')[1] || location // Remove # if present const hashData = decodeURIComponent(hash) const target = @@ -98,16 +100,20 @@ function enableInPageLinks(): void { } } -export default function setupInPageLinks(enabled: boolean): void { +export default function setupInPageLinks(requested: boolean): void { const { mode } = settings - if (enabled) { - if (checkMode(mode)) { - advise(getModeData(5)) - } else { - enableInPageLinks() - } - } else { + if (!requested) { log('In page linking not enabled') + return + } + + if (state.inPageLinks?.findTarget) return // Already wired up + + if (checkMode(mode)) { + advise(getModeData(5)) + return } + + enableInPageLinks() } diff --git a/packages/child/page/links.units.test.ts b/packages/child/page/links.units.test.ts index c405284ac..1726ca000 100644 --- a/packages/child/page/links.units.test.ts +++ b/packages/child/page/links.units.test.ts @@ -15,7 +15,7 @@ vi.mock('../console', () => ({ advise: vi.fn(), log: vi.fn() })) vi.mock('../send/message', () => ({ __esModule: true, default: vi.fn() })) vi.mock('../events/listeners', () => ({ addEventListener: vi.fn() })) vi.mock('../values/settings', () => ({ - default: { mode: 0 }, + default: { mode: 0, inPageLinks: true }, })) vi.mock('../values/state', () => ({ default: { inPageLinks: null }, diff --git a/packages/child/received/process-request.ts b/packages/child/received/process-request.ts index 4ba0bd4a4..694ff0ab3 100644 --- a/packages/child/received/process-request.ts +++ b/packages/child/received/process-request.ts @@ -5,6 +5,7 @@ import pageInfo from './page-info' import parentInfo from './parent-info' import reset from './reset' import resize from './resize' +import update from './update' import { getData } from './utils' const moveToAnchor = (event: MessageEvent): void => @@ -19,4 +20,5 @@ export default { pageInfo, parentInfo, message, + update, } diff --git a/packages/child/received/update.test.ts b/packages/child/received/update.test.ts new file mode 100644 index 000000000..8fc5d4456 --- /dev/null +++ b/packages/child/received/update.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import settings from '../values/settings' + +vi.mock('../console', () => ({ + errorBoundary: (fn: any) => fn, + event: vi.fn(), + log: vi.fn(), + setConsoleOptions: vi.fn(), + warn: vi.fn(), +})) +vi.mock('../events/mouse', () => ({ default: vi.fn() })) +vi.mock('../page/css', () => ({ + setBodyStyle: vi.fn(), + setMargin: vi.fn(), +})) +vi.mock('../page/links', () => ({ default: vi.fn() })) + +const updateFromParent = (await import('./update')).default +const consoleMod = await import('../console') +const setupMouseEvents = (await import('../events/mouse')).default +const cssMod = await import('../page/css') +const setupInPageLinks = (await import('../page/links')).default + +const buildEvent = (id: string, fields: Record): MessageEvent => { + // Wire format mirrors createOutgoingMessage on the parent side, prefixed + // with the iframeResizer header and the "update:" event type. + const data = [ + id, + '8', + fields.sizeWidth ?? 'false', + fields.logging ?? 'false', + '32', + 'true', + fields.autoResize ?? 'true', + '0', + 'auto', + '', + '', + fields.tolerance ?? '0', + fields.inPageLinks ?? 'false', + 'child', + 'auto', + fields.mouseEvents ?? 'false', + '0', + '0', + fields.sizeHeight ?? 'true', + '', + '', + '0', + '', + fields.logExpand ?? 'false', + ].join(':') + return { data: `[iFrameSizer]update:${data}` } as MessageEvent +} + +describe('child/received/update', () => { + beforeEach(() => { + vi.clearAllMocks() + settings.mouseEvents = false + settings.inPageLinks = false + settings.logging = false + settings.bodyBackground = '' + settings.bodyMarginStr = '' + settings.bodyPadding = '' + }) + + it('updates console settings on every update', () => { + const event = buildEvent('edge1', { + logging: 'true', + logExpand: 'true', + }) + updateFromParent(event) + + expect(settings.logging).toBe(true) + expect(settings.logExpand).toBe(true) + expect(consoleMod.setConsoleOptions).toHaveBeenCalledWith({ + id: settings.parentId, + enabled: true, + expand: true, + }) + }) + + it('does not touch body styles when they are unchanged', () => { + // Match the values the buildEvent fixture will deliver so previous === incoming + settings.bodyBackground = '' + settings.bodyMarginStr = '0' + settings.bodyPadding = '' + + updateFromParent(buildEvent('edge1', {})) + + expect(cssMod.setMargin).not.toHaveBeenCalled() + expect(cssMod.setBodyStyle).not.toHaveBeenCalled() + }) + + it('calls setupMouseEvents only when mouseEvents transitions false → true', () => { + settings.mouseEvents = false + + updateFromParent(buildEvent('edge1', { mouseEvents: 'true' })) + expect(setupMouseEvents).toHaveBeenCalledTimes(1) + + // already true, second update with the same value should not re-attach + updateFromParent(buildEvent('edge1', { mouseEvents: 'true' })) + expect(setupMouseEvents).toHaveBeenCalledTimes(1) + }) + + it('does not call setupMouseEvents when mouseEvents stays false', () => { + settings.mouseEvents = false + + updateFromParent(buildEvent('edge1', { mouseEvents: 'false' })) + expect(setupMouseEvents).not.toHaveBeenCalled() + }) + + it('calls setupInPageLinks only when inPageLinks transitions false → true', () => { + settings.inPageLinks = false + + updateFromParent(buildEvent('edge1', { inPageLinks: 'true' })) + expect(setupInPageLinks).toHaveBeenCalledWith(true) + expect(setupInPageLinks).toHaveBeenCalledTimes(1) + + updateFromParent(buildEvent('edge1', { inPageLinks: 'true' })) + expect(setupInPageLinks).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/child/received/update.ts b/packages/child/received/update.ts new file mode 100644 index 000000000..1c70acb45 --- /dev/null +++ b/packages/child/received/update.ts @@ -0,0 +1,50 @@ +import { SEPARATOR, UPDATE } from '@iframe-resizer/common/consts' + +import { event as consoleEvent, log, setConsoleOptions } from '../console' +import setupMouseEvents from '../events/mouse' +import { setBodyStyle, setMargin } from '../page/css' +import setupInPageLinks from '../page/links' +import readDataFromParent from '../read/from-parent' +import map2settings from '../utils/map-settings' +import settings from '../values/settings' +import { getData } from './utils' + +export default function updateFromParent(event: MessageEvent): void { + consoleEvent(UPDATE) + + const previous = { + bodyBackground: settings.bodyBackground, + bodyMarginStr: settings.bodyMarginStr, + bodyPadding: settings.bodyPadding, + inPageLinks: settings.inPageLinks, + mouseEvents: settings.mouseEvents, + } + + const data = getData(event).split(SEPARATOR) + const incoming = readDataFromParent(data) + + map2settings(incoming) + + setConsoleOptions({ + id: settings.parentId, + enabled: settings.logging, + expand: settings.logExpand, + }) + + // Only touch body styles when the value actually changed, so updates that + // don't include body* options don't override styles set by the page itself. + if (settings.bodyMarginStr !== previous.bodyMarginStr) setMargin(settings) + if (settings.bodyBackground !== previous.bodyBackground) + setBodyStyle('background', settings.bodyBackground) + if (settings.bodyPadding !== previous.bodyPadding) + setBodyStyle('padding', settings.bodyPadding) + + if (settings.mouseEvents && !previous.mouseEvents) setupMouseEvents(settings) + if (settings.inPageLinks && !previous.inPageLinks) setupInPageLinks(true) + + // Strip license-key fields before logging, matching child/init.ts. + log( + 'Settings updated from parent', + (({ key, key2, ...rest }) => rest)(settings), + ) +} diff --git a/packages/common/consts.ts b/packages/common/consts.ts index d8127d6b8..880341c38 100644 --- a/packages/common/consts.ts +++ b/packages/common/consts.ts @@ -28,6 +28,7 @@ export const SCROLL_BY = 'scrollBy' export const SCROLL_TO = 'scrollTo' export const SCROLL_TO_OFFSET = 'scrollToOffset' export const TITLE = 'title' +export const UPDATE = 'update' export const BASE = 10 export const SINGLE = 1 diff --git a/packages/core/checks/min-child-version.test.ts b/packages/core/checks/min-child-version.test.ts new file mode 100644 index 000000000..a253947b0 --- /dev/null +++ b/packages/core/checks/min-child-version.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import settings from '../values/settings' +import meetsMinChildVersion from './min-child-version' + +describe('core/checks/min-child-version', () => { + beforeEach(() => { + vi.resetModules() + for (const key of Object.keys(settings)) delete settings[key] + }) + + it('returns false when settings missing for id', () => { + expect(meetsMinChildVersion('missing')).toBe(false) + }) + + it('returns false when childVersion is undefined (legacy)', () => { + settings.id1 = { childVersion: undefined } + expect(meetsMinChildVersion('id1')).toBe(false) + }) + + it('returns false when childVersion major is less than 6', () => { + settings.id1 = { childVersion: '5.4.0' } + expect(meetsMinChildVersion('id1')).toBe(false) + }) + + it('returns true when childVersion major is exactly 6', () => { + settings.id1 = { childVersion: '6.0.0' } + expect(meetsMinChildVersion('id1')).toBe(true) + }) + + it('returns true when childVersion is a 6.x prerelease', () => { + settings.id1 = { childVersion: '6.1.0-beta.1' } + expect(meetsMinChildVersion('id1')).toBe(true) + }) + + it('returns true when childVersion major is greater than 6', () => { + settings.id1 = { childVersion: '7.0.0' } + expect(meetsMinChildVersion('id1')).toBe(true) + }) + + it('returns false for non-string version', () => { + settings.id1 = { childVersion: 6 as any } + expect(meetsMinChildVersion('id1')).toBe(false) + }) +}) diff --git a/packages/core/checks/min-child-version.ts b/packages/core/checks/min-child-version.ts new file mode 100644 index 000000000..145346571 --- /dev/null +++ b/packages/core/checks/min-child-version.ts @@ -0,0 +1,10 @@ +import settings from '../values/settings' + +const REQUIRED_MAJOR = 6 + +export default function meetsMinChildVersion(id: string): boolean { + const version = settings[id]?.childVersion + if (typeof version !== 'string') return false + const major = Number.parseInt(version.split('.')[0], 10) + return Number.isFinite(major) && major >= REQUIRED_MAJOR +} diff --git a/packages/core/index.test.ts b/packages/core/index.test.ts index d92863d0e..6e935d977 100644 --- a/packages/core/index.test.ts +++ b/packages/core/index.test.ts @@ -9,11 +9,12 @@ it('throws TypeError when options is not an object', async () => { expect(() => connectResizer(null)).toThrow(TypeError) }) -it('warns and returns existing api when already setup', async () => { +it('runs update flow and returns existing api when already setup', async () => { vi.mock('./checks/id', () => ({ default: vi.fn(() => 'abc') })) vi.mock('./listeners', () => ({ default: vi.fn() })) vi.mock('./setup/logging', () => ({ default: vi.fn() })) vi.mock('./setup', () => ({ default: vi.fn() })) + vi.mock('./setup/update', () => ({ default: vi.fn() })) vi.mock('./console', () => { const errorBoundary = vi.fn( (_, fn) => @@ -26,12 +27,13 @@ it('warns and returns existing api when already setup', async () => { }) const { default: connectResizer } = await import('./index') - const consoleMod = await import('./console') + const updateMod = await import('./setup/update') const iframe = { iframeResizer: { api: true } } - const fn = connectResizer({}) + const options = { y: 2 } + const fn = connectResizer(options) const api = fn(iframe) - expect(consoleMod.warn).toHaveBeenCalled() + expect(updateMod.default).toHaveBeenCalledWith(iframe, options) expect(api).toBe(iframe.iframeResizer) }) diff --git a/packages/core/index.ts b/packages/core/index.ts index 83719fba3..2abfaf915 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -3,10 +3,11 @@ import { LABEL } from '@iframe-resizer/common/consts' import ensureHasId from './checks/id' import checkManualLogging from './checks/manual-logging' -import { errorBoundary, event as consoleEvent, warn } from './console' +import { errorBoundary } from './console' import setupEventListenersOnce from './listeners' import setupIframe from './setup' import setupLogging from './setup/logging' +import updateIframe from './setup/update' import type { IFrameComponent, IFrameObject, IFrameOptions } from './types' export type * from './types' @@ -28,8 +29,7 @@ export default function connectResizer( const id = ensureHasId(iframe, options) if (LABEL in iframe) { - consoleEvent(id, 'alreadySetup') - warn(id, `Ignored iframe (${id}), already setup.`) + errorBoundary(id, updateIframe)(iframe, options) } else { setupLogging(id, options) errorBoundary(id, setupIframe)(iframe, options) diff --git a/packages/core/setup/update.test.ts b/packages/core/setup/update.test.ts new file mode 100644 index 000000000..a63438c76 --- /dev/null +++ b/packages/core/setup/update.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../send/trigger', () => ({ default: vi.fn() })) +vi.mock('../send/outgoing', () => ({ + default: vi.fn(() => 'edge1:8:true:rest'), +})) +vi.mock('../checks/min-child-version', () => ({ default: vi.fn() })) +vi.mock('../console', () => ({ + error: vi.fn(), + event: vi.fn(), + log: vi.fn(), +})) +vi.mock('./target-origin', () => ({ setTargetOrigin: vi.fn() })) +vi.mock('../send/offset', () => ({ default: vi.fn() })) +vi.mock('../checks/options', () => ({ + default: vi.fn((_id, options) => options || {}), +})) +vi.mock('../values/settings', () => ({ + default: {} as Record, +})) + +const trigger = (await import('../send/trigger')).default +const createOutgoingMessage = (await import('../send/outgoing')).default +const meetsMinChildVersion = (await import('../checks/min-child-version')) + .default +const console = await import('../console') +const settings = (await import('../values/settings')).default +const updateIframe = (await import('./update')).default + +describe('core/setup/update', () => { + beforeEach(() => { + vi.clearAllMocks() + for (const key of Object.keys(settings)) delete settings[key] + }) + + it('errors and skips dispatch when child version < 6', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(false) + settings.edge1 = { mode: 0, direction: 'vertical' } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: true }) + + expect(console.error).toHaveBeenCalled() + expect(trigger).not.toHaveBeenCalled() + }) + + it('merges options into settings and dispatches UPDATE message', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { + mode: 0, + direction: 'vertical', + log: false, + mouseEvents: false, + } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { + log: true, + onMouseEnter: () => {}, + }) + + // Setting was merged + expect(settings.edge1.log).toBe(true) + expect(settings.edge1.mouseEvents).toBe(true) + // Outgoing message built and dispatched with update: prefix + expect(createOutgoingMessage).toHaveBeenCalledWith('edge1') + expect(trigger).toHaveBeenCalledWith( + 'update', + 'update:edge1:8:true:rest', + 'edge1', + ) + }) + + it('does not flip mouseEvents to true when no mouse handlers passed', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical', mouseEvents: false } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: true }) + + expect(settings.edge1.mouseEvents).toBe(false) + }) +}) diff --git a/packages/core/setup/update.ts b/packages/core/setup/update.ts new file mode 100644 index 000000000..a28ec6aaa --- /dev/null +++ b/packages/core/setup/update.ts @@ -0,0 +1,47 @@ +import { hasOwn, setMode } from '@iframe-resizer/common' +import { UPDATE } from '@iframe-resizer/common/consts' + +import meetsMinChildVersion from '../checks/min-child-version' +import checkOptions from '../checks/options' +import { error, event as consoleEvent, log } from '../console' +import setOffsetSize from '../send/offset' +import createOutgoingMessage from '../send/outgoing' +import trigger from '../send/trigger' +import settings from '../values/settings' +import { setTargetOrigin } from './target-origin' + +const hasMouseEvents = (options: Record): boolean => + hasOwn(options, 'onMouseEnter') || hasOwn(options, 'onMouseLeave') + +function mergeOptions(id: string, options: Record): void { + settings[id] = { + ...settings[id], + ...checkOptions(id, options), + } + + if (hasMouseEvents(options)) settings[id].mouseEvents = true + if (hasOwn(options, 'mode')) settings[id].mode = setMode(options) + + setOffsetSize(id, options) + setTargetOrigin(id) +} + +export default function updateIframe( + iframe: HTMLIFrameElement, + options: Record, +): void { + const { id } = iframe + consoleEvent(id, UPDATE) + + if (!meetsMinChildVersion(id)) { + error( + id, + `Updating options on a bound iframe requires @iframe-resizer/child v6 or later in the iframe.`, + ) + return + } + + mergeOptions(id, options) + log(id, 'Sending update message to iframe') + trigger(UPDATE, `${UPDATE}:${createOutgoingMessage(id)}`, id) +} diff --git a/packages/react/index.test.tsx b/packages/react/index.test.tsx index 7de08b8d0..9b35352cc 100644 --- a/packages/react/index.test.tsx +++ b/packages/react/index.test.tsx @@ -140,6 +140,49 @@ describe('React IframeResizer component', () => { }) }) + test('re-binds connectResizer when iframe-resizer options change', async () => { + const connectResizer = (await import('@iframe-resizer/core')).default + connectResizer.mockClear() + + function Wrapper({ logFlag }: { logFlag: boolean }) { + return ( + + ) + } + + await act(async () => { + root.render() + await Promise.resolve() + }) + + expect(connectResizer).toHaveBeenCalledTimes(1) + + // Re-render with a different option value; the second useEffect fires + // and re-calls connectResizer — which routes through the update path. + await act(async () => { + root.render() + await Promise.resolve() + }) + + expect(connectResizer).toHaveBeenCalledTimes(2) + + // Re-rendering with the same options does NOT fire another bind. + await act(async () => { + root.render() + await Promise.resolve() + }) + + expect(connectResizer).toHaveBeenCalledTimes(2) + + await act(async () => { + root.unmount() + }) + }) + test('onBeforeClose returns false and logs warning', async () => { const connectResizer = (await import('@iframe-resizer/core')).default diff --git a/packages/react/index.tsx b/packages/react/index.tsx index 57765efa6..aab808577 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -13,6 +13,7 @@ import React, { } from 'react' import filterIframeAttribs from './filter-iframe-attribs' +import buildOptionsKey from './options-key' import type { IFrameForwardRef, IFrameResizerProps } from './types' export type { IFrameForwardRef, IFrameResizerProps } from './types' @@ -45,8 +46,7 @@ function IframeResizer( return false } - // This hook is only run once, as once iframe-resizer is bound, it will - // deal with changes to the element and does not need recalling + // First mount: establish the iframe-resizer binding and clean up on unmount. useEffect(() => { const iframe = iframeRef.current @@ -64,6 +64,21 @@ function IframeResizer( } }, []) // eslint-disable-line react-hooks/exhaustive-deps + // Re-bind when iframe-resizer-relevant props change. The first mount above + // already created the binding, so subsequent calls take the update path in + // core, which sends an UPDATE message to the child. + const optionsKey = buildOptionsKey(props) + const isFirstUpdateRef = useRef(true) + useEffect(() => { + if (isFirstUpdateRef.current) { + isFirstUpdateRef.current = false + return + } + const iframe = iframeRef.current + if (!iframe) return + connectResizer({ ...props, onBeforeClose })(iframe) + }, [optionsKey]) // eslint-disable-line react-hooks/exhaustive-deps + useImperativeHandle( ref, () => ({ diff --git a/packages/react/options-key.ts b/packages/react/options-key.ts new file mode 100644 index 000000000..c92ea4326 --- /dev/null +++ b/packages/react/options-key.ts @@ -0,0 +1,43 @@ +// Build a stable string key from the iframe-resizer options that affect the +// wire payload. Used as a useEffect dependency so we re-call connectResizer +// (which then takes the update path in core) when the user changes options. +// Callback identity is intentionally excluded — callbacks aren't part of the +// wire payload and would otherwise cause spurious updates on every render. +export default ({ + id, + license, + log, + logExpand, + bodyBackground, + bodyMargin, + bodyPadding, + checkOrigin, + direction, + inPageLinks, + offsetSize, + scrolling, + tolerance, + waitForLoad, + warningTimeout, + onMouseEnter, + onMouseLeave, +}: Record): string => + JSON.stringify({ + id, + license, + log, + logExpand, + bodyBackground, + bodyMargin, + bodyPadding, + checkOrigin, + direction, + inPageLinks, + offsetSize, + scrolling, + tolerance, + waitForLoad, + warningTimeout, + hasMouseEnter: typeof onMouseEnter === 'function', + hasMouseLeave: typeof onMouseLeave === 'function', + }) diff --git a/packages/solid/IframeResizer.tsx b/packages/solid/IframeResizer.tsx index 7fb7b1b4d..17fcde770 100644 --- a/packages/solid/IframeResizer.tsx +++ b/packages/solid/IframeResizer.tsx @@ -3,9 +3,10 @@ import type { IFrameComponent } from '@iframe-resizer/core' import connectResizer from '@iframe-resizer/core' import acg from 'auto-console-group' import type { JSX } from 'solid-js' -import { onCleanup, onMount, splitProps } from 'solid-js' +import { createEffect, onCleanup, onMount, splitProps } from 'solid-js' import type { IFrameResizerProps } from './types' +import pickWireOptions from './wire-options' export type { IFrameResizerMethods, IFrameResizerProps } from './types' @@ -40,18 +41,6 @@ export default function IframeResizer(props: IFrameResizerProps): JSX.Element { onMount(() => { const { - license, - bodyBackground, - bodyMargin, - bodyPadding, - checkOrigin, - direction, - inPageLinks, - log, - offsetSize, - scrolling, - tolerance, - warningTimeout, onReady, onMessage, onResized, @@ -66,22 +55,7 @@ export default function IframeResizer(props: IFrameResizerProps): JSX.Element { consoleGroup.event('setup') const options: Record = { - ...Object.fromEntries( - Object.entries({ - license, - bodyBackground, - bodyMargin, - bodyPadding, - checkOrigin, - direction, - inPageLinks, - log, - offsetSize, - scrolling, - tolerance, - warningTimeout, - }).filter(([, v]) => v !== undefined), - ), + ...pickWireOptions(local), onBeforeClose: () => { consoleGroup.warn( 'Close method is disabled, use Solid to remove the iframe.', @@ -102,8 +76,8 @@ export default function IframeResizer(props: IFrameResizerProps): JSX.Element { const resizer = connectResizer(options)(iframeEl) - consoleGroup.expand(log === 'expanded') - if (log) consoleGroup.log('Created Solid component') + consoleGroup.expand(local.log === 'expanded') + if (local.log) consoleGroup.log('Created Solid component') if (typeof setRef === 'function') { setRef({ @@ -120,6 +94,22 @@ export default function IframeResizer(props: IFrameResizerProps): JSX.Element { }) }) + // Re-bind when iframe-resizer-relevant props change. The first call inside + // onMount establishes the binding; this effect tracks subsequent changes + // and routes through the update path in core. + let isFirstUpdate = true + createEffect(() => { + const updateOptions = pickWireOptions(local) + + if (isFirstUpdate) { + isFirstUpdate = false + return + } + if (!iframeEl) return + + connectResizer(updateOptions)(iframeEl) + }) + // eslint-disable-next-line jsx-a11y/iframe-has-title return