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/package-lock.json b/package-lock.json index ae05689ec..6d204e136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21228,9 +21228,6 @@ }, "packages/child": {}, "packages/common": {}, - "packages/consts": { - "extraneous": true - }, "packages/core": {}, "packages/parent": {}, "packages/react": {} diff --git a/packages/angular/directive.test.ts b/packages/angular/directive.test.ts index ee4242058..a67fff11c 100644 --- a/packages/angular/directive.test.ts +++ b/packages/angular/directive.test.ts @@ -338,11 +338,11 @@ describe('Angular IframeResizerDirective', () => { directive.ngAfterViewInit() expect(mockGroupExpand).toHaveBeenCalledTimes(1) - expect(mockGroupExpand).toHaveBeenCalledWith(undefined) + expect(mockGroupExpand).toHaveBeenCalledWith(false) }) - test('expand passes logExpand option when set', () => { - directive.options = { license: 'TEST', logExpand: true } + test('expand is true when log option is "expanded"', () => { + directive.options = { license: 'TEST', log: 'expanded' } directive.ngAfterViewInit() expect(mockGroupExpand).toHaveBeenCalledWith(true) diff --git a/packages/angular/directive.ts b/packages/angular/directive.ts index 4bbbecb8d..e9afee97f 100644 --- a/packages/angular/directive.ts +++ b/packages/angular/directive.ts @@ -8,8 +8,10 @@ import { EventEmitter, Input, Output, + type SimpleChanges, } from '@angular/core' import { esModuleInterop } from '@iframe-resizer/common' +import { EXPAND, LOG_EXPANDED } from '@iframe-resizer/common/consts' import type { IFrameComponent, IFrameMessageData, @@ -55,7 +57,7 @@ export class IframeResizerDirective { return this.resizer } - @Input() options: IFrameOptions & { logExpand?: boolean } = { + @Input() options: IFrameOptions = { license: '', } @@ -63,17 +65,11 @@ 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({ - ...this.options, + private buildOptions(): IFrameOptions { + const { logExpand: _logExpand, ...options } = this + .options as IFrameOptions & Record + return { + ...options, onBeforeClose: () => { this.consoleGroup.event('close') @@ -98,7 +94,32 @@ 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.log === EXPAND || this.options.log === LOG_EXPANDED, + ) + + 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/methods/move-to-anchor.test.ts b/packages/child/methods/move-to-anchor.test.ts index 128c37687..5931d275e 100644 --- a/packages/child/methods/move-to-anchor.test.ts +++ b/packages/child/methods/move-to-anchor.test.ts @@ -1,20 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('../console', () => ({ advise: vi.fn() })) - -const { advise } = await import('../console') const state = (await import('../values/state')).default const moveToAnchor = (await import('./move-to-anchor')).default describe('child/methods/move-to-anchor', () => { beforeEach(() => { vi.restoreAllMocks() - state.inPageLinks = { findTarget: vi.fn() } + state.findInPageLinkTarget = vi.fn() }) it('calls findTarget with the provided anchor', () => { moveToAnchor('section-1') - expect(state.inPageLinks.findTarget).toHaveBeenCalledWith('section-1') + expect(state.findInPageLinkTarget).toHaveBeenCalledWith('section-1') }) it('throws TypeError when anchor is not a string', () => { @@ -22,9 +19,8 @@ describe('child/methods/move-to-anchor', () => { expect(() => moveToAnchor(123)).toThrowError(TypeError) }) - it('advises when inPageLinks is not enabled', () => { - state.inPageLinks = undefined - moveToAnchor('section-1') - expect(advise).toHaveBeenCalledWith(expect.stringContaining('inPageLinks')) + it('is a no-op when inPageLinks is not enabled', () => { + state.findInPageLinkTarget = null + expect(() => moveToAnchor('section-1')).not.toThrow() }) }) diff --git a/packages/child/methods/move-to-anchor.ts b/packages/child/methods/move-to-anchor.ts index 6b7e071f1..c73846367 100644 --- a/packages/child/methods/move-to-anchor.ts +++ b/packages/child/methods/move-to-anchor.ts @@ -1,18 +1,9 @@ import { typeAssert } from '@iframe-resizer/common' import { STRING } from '@iframe-resizer/common/consts' -import { advise } from '../console' import state from '../values/state' export default function moveToAnchor(anchor: string): void { typeAssert(anchor, STRING, 'parentIframe.moveToAnchor(anchor) anchor') - - if (!state.inPageLinks?.findTarget) { - advise( - 'Move to Anchor

moveToAnchor() requires inPageLinks to be enabled', - ) - return - } - - state.inPageLinks.findTarget(anchor) + state.findInPageLinkTarget?.(anchor) } diff --git a/packages/child/page/links.branches.test.ts b/packages/child/page/links.branches.test.ts index 5b74bd6d6..c32ff5df7 100644 --- a/packages/child/page/links.branches.test.ts +++ b/packages/child/page/links.branches.test.ts @@ -16,15 +16,16 @@ describe('child/page/links branches', () => { beforeEach(() => { vi.restoreAllMocks() document.body.innerHTML = '' - state.inPageLinks = undefined + state.findInPageLinkTarget = null settings.mode = 0 + settings.inPageLinks = true sendMessage.mockClear() }) it('enabled=false logs and does not set up handlers', () => { setupInPageLinks(false) - expect(state.inPageLinks).toBeUndefined() + expect(state.findInPageLinkTarget).toBeNull() expect(sendMessage).not.toHaveBeenCalled() }) @@ -34,7 +35,7 @@ describe('child/page/links branches', () => { setupInPageLinks(true) expect(consoleMod.advise).toHaveBeenCalled() - expect(state.inPageLinks).toBeUndefined() + expect(state.findInPageLinkTarget).toBeNull() expect(sendMessage).not.toHaveBeenCalled() }) @@ -69,7 +70,7 @@ describe('child/page/links branches', () => { }) it('findTarget jumps when element exists', () => { - // Enable and use state.inPageLinks.findTarget + // Enable and use state.findInPageLinkTarget const target = document.createElement('div') target.id = 'found' target.getBoundingClientRect = () => ({ left: 7, top: 11 }) @@ -77,7 +78,7 @@ describe('child/page/links branches', () => { setupInPageLinks(true) - state.inPageLinks.findTarget('#found') + state.findInPageLinkTarget('#found') expect(sendMessage).toHaveBeenCalledWith(11, 7, SCROLL_TO_OFFSET) }) @@ -91,7 +92,7 @@ describe('child/page/links branches', () => { setupInPageLinks(true) // Pass location without # prefix - state.inPageLinks.findTarget('nohash') + state.findInPageLinkTarget('nohash') expect(sendMessage).toHaveBeenCalledWith(15, 5, SCROLL_TO_OFFSET) }) @@ -104,7 +105,7 @@ describe('child/page/links branches', () => { setupInPageLinks(true) - state.inPageLinks.findTarget('#byname') + state.findInPageLinkTarget('#byname') expect(sendMessage).toHaveBeenCalledWith(9, 3, SCROLL_TO_OFFSET) }) diff --git a/packages/child/page/links.test.ts b/packages/child/page/links.test.ts index 92f626a6f..ad745dd26 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.findInPageLinkTarget = null }) test('setup and findTarget sends message for existing id', () => { @@ -26,8 +28,15 @@ describe('child/page/links', () => { setupInPageLinks(true) // use the registered finder directly - state.inPageLinks.findTarget('#t1') + state.findInPageLinkTarget('#t1') 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..ddebb2a8a 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 = @@ -65,17 +67,25 @@ export function checkLocationHash(): void { } } +export function handleAnchorClick(e: Event): void { + if (settings.inPageLinks !== true) return + + const target = e.target as Element | null + const link = target?.closest?.('a[href^="#"]') + if (!link) return + + const href = link.getAttribute('href') + if (!href || href === '#') return + + e.preventDefault() + findTarget(href) +} + export function bindAnchors(): void { - for (const link of document.querySelectorAll('a[href^="#"]')) { - const href = link.getAttribute('href') - - if (href && href !== '#') { - addEventListener(link, 'click', (e) => { - e.preventDefault() - findTarget(href) - }) - } - } + // Delegated listener: catches anchors added after init, and lets disable + // (settings.inPageLinks = false) restore native anchor behaviour because + // preventDefault() is gated inside the handler. + addEventListener(document, 'click', handleAnchorClick) } function bindLocationHash(): void { @@ -93,21 +103,23 @@ function enableInPageLinks(): void { bindLocationHash() initCheck() - state.inPageLinks = { - findTarget, - } + state.findInPageLinkTarget = findTarget } -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.findInPageLinkTarget) 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..51fb75b7d 100644 --- a/packages/child/page/links.units.test.ts +++ b/packages/child/page/links.units.test.ts @@ -15,10 +15,10 @@ 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 }, + default: { findInPageLinkTarget: null }, })) vi.mock('../../common/mode', () => ({ checkMode: vi.fn(() => false), @@ -29,7 +29,7 @@ describe('child/page/links unit tests', () => { beforeEach(() => { vi.clearAllMocks() document.body.innerHTML = '' - state.inPageLinks = null + state.findInPageLinkTarget = null }) describe('getPagePosition', () => { @@ -256,69 +256,102 @@ describe('child/page/links unit tests', () => { }) describe('bindAnchors', () => { - test('adds click listeners to anchor elements with hash hrefs', () => { - const a1 = document.createElement('a') - a1.setAttribute('href', '#section1') - const a2 = document.createElement('a') - a2.setAttribute('href', '#section2') - document.body.append(a1, a2) - + test('attaches a single delegated click listener on document', () => { bindAnchors() - expect(addEventListener).toHaveBeenCalledTimes(2) - expect(vi.mocked(addEventListener).mock.calls[0][0]).toBe(a1) + expect(addEventListener).toHaveBeenCalledTimes(1) + expect(vi.mocked(addEventListener).mock.calls[0][0]).toBe(document) expect(vi.mocked(addEventListener).mock.calls[0][1]).toBe('click') - expect(vi.mocked(addEventListener).mock.calls[1][0]).toBe(a2) - expect(vi.mocked(addEventListener).mock.calls[1][1]).toBe('click') }) - test('skips anchors with href="#"', () => { - const a1 = document.createElement('a') - a1.setAttribute('href', '#') - const a2 = document.createElement('a') - a2.setAttribute('href', '#valid') - document.body.append(a1, a2) - + function captureDelegatedHandler(): (e: Event) => void { + let captured: (e: Event) => void + vi.mocked(addEventListener).mockImplementation( + (_el, _evt, handler: any) => { + captured = handler + }, + ) bindAnchors() + return captured! + } - expect(addEventListener).toHaveBeenCalledTimes(1) - expect(vi.mocked(addEventListener).mock.calls[0][0]).toBe(a2) + test('handler calls findTarget and prevents default for hash anchor', () => { + const target = document.createElement('div') + target.id = 'clickTarget' + document.body.append(target) + + const a = document.createElement('a') + a.setAttribute('href', '#clickTarget') + document.body.append(a) + + const handler = captureDelegatedHandler() + const mockEvent = { target: a, preventDefault: vi.fn() } + handler(mockEvent as unknown as Event) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(sendMessage).toHaveBeenCalled() }) - test('does nothing when no matching anchors exist', () => { + test('handler ignores anchor with href="#"', () => { const a = document.createElement('a') - a.setAttribute('href', 'http://example.com') + a.setAttribute('href', '#') document.body.append(a) - bindAnchors() + const handler = captureDelegatedHandler() + const mockEvent = { target: a, preventDefault: vi.fn() } + handler(mockEvent as unknown as Event) + + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + expect(sendMessage).not.toHaveBeenCalled() + }) + + test('handler ignores clicks outside hash anchors', () => { + const div = document.createElement('div') + document.body.append(div) + + const handler = captureDelegatedHandler() + const mockEvent = { target: div, preventDefault: vi.fn() } + handler(mockEvent as unknown as Event) - expect(addEventListener).not.toHaveBeenCalled() + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + expect(sendMessage).not.toHaveBeenCalled() }) - test('click handler calls findTarget and prevents default', () => { + test('handler walks up to find ancestor hash anchor', () => { const target = document.createElement('div') - target.id = 'clickTarget' + target.id = 'nested' document.body.append(target) const a = document.createElement('a') - a.setAttribute('href', '#clickTarget') + a.setAttribute('href', '#nested') + const inner = document.createElement('span') + a.append(inner) document.body.append(a) - // Use real addEventListener to capture the handler - let capturedHandler: (e: Event) => void - vi.mocked(addEventListener).mockImplementation( - (_el, _evt, handler: any) => { - capturedHandler = handler - }, - ) - - bindAnchors() - - const mockEvent = { preventDefault: vi.fn() } - capturedHandler!(mockEvent as unknown as Event) + const handler = captureDelegatedHandler() + const mockEvent = { target: inner, preventDefault: vi.fn() } + handler(mockEvent as unknown as Event) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(sendMessage).toHaveBeenCalled() }) + + test('handler is a no-op when settings.inPageLinks is false', async () => { + const settingsMod = (await import('../values/settings')).default + settingsMod.inPageLinks = false + + const a = document.createElement('a') + a.setAttribute('href', '#x') + document.body.append(a) + + const handler = captureDelegatedHandler() + const mockEvent = { target: a, preventDefault: vi.fn() } + handler(mockEvent as unknown as Event) + + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + expect(sendMessage).not.toHaveBeenCalled() + + settingsMod.inPageLinks = true + }) }) }) diff --git a/packages/child/received/process-request.test.ts b/packages/child/received/process-request.test.ts index a02e7a6f1..19a511df4 100644 --- a/packages/child/received/process-request.test.ts +++ b/packages/child/received/process-request.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Mocks -const mockFindTarget = vi.fn(() => 'target-found') -vi.mock('../values/state', () => ({ - default: { inPageLinks: { findTarget: mockFindTarget } }, -})) +const mockFindTarget = vi.fn() +const mockState: { findInPageLinkTarget: ((loc: string) => void) | null } = { + findInPageLinkTarget: mockFindTarget, +} +vi.mock('../values/state', () => ({ default: mockState })) vi.mock('./utils', () => ({ getData: (e) => e.data })) vi.mock('./init', () => ({ default: vi.fn() })) vi.mock('./message', () => ({ default: vi.fn() })) @@ -16,7 +17,8 @@ vi.mock('./resize', () => ({ default: vi.fn() })) describe('child/received/process-request', () => { beforeEach(() => { vi.restoreAllMocks() - mockFindTarget.mockReset().mockReturnValue('target-found') + mockFindTarget.mockReset() + mockState.findInPageLinkTarget = mockFindTarget }) it('exposes request handlers and alias', async () => { @@ -34,10 +36,16 @@ describe('child/received/process-request', () => { expect(mod.default.inPageLink).toBe(mod.default.moveToAnchor) }) - it('moveToAnchor delegates to state.inPageLinks.findTarget', async () => { + it('moveToAnchor delegates to state.findInPageLinkTarget', async () => { const mod = await import('./process-request') - const result = mod.default.moveToAnchor({ data: 'anchor-123' }) + mod.default.moveToAnchor({ data: 'anchor-123' }) expect(mockFindTarget).toHaveBeenCalledWith('anchor-123') - expect(result).toBe('target-found') + }) + + it('moveToAnchor is a no-op when findInPageLinkTarget is null', async () => { + mockState.findInPageLinkTarget = null + const mod = await import('./process-request') + expect(() => mod.default.moveToAnchor({ data: 'anchor-123' })).not.toThrow() + expect(mockFindTarget).not.toHaveBeenCalled() }) }) diff --git a/packages/child/received/process-request.ts b/packages/child/received/process-request.ts index 4ba0bd4a4..2bf79551c 100644 --- a/packages/child/received/process-request.ts +++ b/packages/child/received/process-request.ts @@ -5,10 +5,11 @@ 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 => - state.inPageLinks.findTarget(getData(event)) + state.findInPageLinkTarget?.(getData(event)) export default { init, @@ -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/child/values/state.test.ts b/packages/child/values/state.test.ts index ffc04ab7a..02f96f5f9 100644 --- a/packages/child/values/state.test.ts +++ b/packages/child/values/state.test.ts @@ -7,6 +7,6 @@ describe('child/values/state', () => { expect(state.firstRun).toBe(true) expect(state.overflowedNodeSet instanceof Set).toBe(true) expect(state.win).toBe(window) - expect(typeof state.inPageLinks).toBe('object') + expect(state.findInPageLinkTarget).toBeNull() }) }) diff --git a/packages/child/values/state.ts b/packages/child/values/state.ts index d654582b7..8beca7e3f 100644 --- a/packages/child/values/state.ts +++ b/packages/child/values/state.ts @@ -6,7 +6,7 @@ export default { height: 0, isHidden: false, initLock: true, - inPageLinks: {} as { findTarget: (location: string) => void }, + findInPageLinkTarget: null as ((location: string) => void) | null, origin: undefined, hasOverflowUpdated: true, overflowedNodeSet: new Set(), 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/console.ts b/packages/core/console.ts index 889548123..c0e61ae01 100644 --- a/packages/core/console.ts +++ b/packages/core/console.ts @@ -49,6 +49,9 @@ export function setupConsole({ } } +export const updateConsoleExpand = (iframeId: string): void => + settings[iframeId]?.console?.expand?.(settings[iframeId]?.logExpand) + const output = (type: string) => (iframeId: string, ...args: any[]): any => 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/methods/attach.test.ts b/packages/core/methods/attach.test.ts index 9228f349c..a826bca64 100644 --- a/packages/core/methods/attach.test.ts +++ b/packages/core/methods/attach.test.ts @@ -1,16 +1,16 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' -vi.mock('@iframe-resizer/common', () => ({ typeAssert: vi.fn() })) vi.mock('../send/trigger', () => ({ default: vi.fn() })) vi.mock('./close', () => ({ default: vi.fn() })) vi.mock('./disconnect', () => ({ default: vi.fn() })) +vi.mock('./move-to-anchor', () => ({ default: vi.fn() })) vi.mock('../values/settings', () => ({ default: {} })) const { default: attachMethods } = await import('./attach') const { default: trigger } = await import('../send/trigger') -const { typeAssert } = await import('@iframe-resizer/common') const closeIframe = (await import('./close')).default const disconnect = (await import('./disconnect')).default +const moveToAnchor = (await import('./move-to-anchor')).default const settings = (await import('../values/settings')).default describe('core/methods/attach', () => { @@ -36,12 +36,7 @@ describe('core/methods/attach', () => { expect(disconnect).toHaveBeenCalledWith(iframe) api.moveToAnchor('hash') - expect(typeAssert).toHaveBeenCalled() - expect(trigger).toHaveBeenCalledWith( - 'Move to anchor', - 'moveToAnchor:hash', - 'if1', - ) + expect(moveToAnchor).toHaveBeenCalledWith('if1', 'hash') api.sendMessage({ a: 1 }) expect(trigger).toHaveBeenCalledWith('message', 'message:{"a":1}', 'if1') diff --git a/packages/core/methods/attach.ts b/packages/core/methods/attach.ts index 9b40a5692..858f51a62 100644 --- a/packages/core/methods/attach.ts +++ b/packages/core/methods/attach.ts @@ -1,11 +1,11 @@ import type { IFrameVersion } from '@iframe-resizer/common' -import { typeAssert } from '@iframe-resizer/common' -import { MESSAGE, STRING, VERSION } from '@iframe-resizer/common/consts' +import { MESSAGE, VERSION } from '@iframe-resizer/common/consts' import trigger from '../send/trigger' import settings from '../values/settings' import closeIframe from './close' import disconnect from './disconnect' +import moveToAnchor from './move-to-anchor' export default function attachMethods(id: string): void { if (settings[id]) { @@ -24,10 +24,7 @@ export default function attachMethods(id: string): void { } }, - moveToAnchor(anchor: string) { - typeAssert(anchor, STRING, 'moveToAnchor(anchor) anchor') - trigger('Move to anchor', `moveToAnchor:${anchor}`, id) - }, + moveToAnchor: moveToAnchor.bind(null, id), sendMessage(message: any) { message = JSON.stringify(message) diff --git a/packages/core/methods/move-to-anchor.test.ts b/packages/core/methods/move-to-anchor.test.ts new file mode 100644 index 000000000..f031f16f3 --- /dev/null +++ b/packages/core/methods/move-to-anchor.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +vi.mock('../send/trigger', () => ({ default: vi.fn() })) +vi.mock('../values/settings', () => ({ default: {} })) + +const { default: moveToAnchor } = await import('./move-to-anchor') +const { default: trigger } = await import('../send/trigger') +const settings = (await import('../values/settings')).default + +describe('core/methods/move-to-anchor', () => { + beforeEach(() => { + vi.clearAllMocks() + for (const k of Object.keys(settings)) delete settings[k] + }) + + test('triggers postMessage when inPageLinks is enabled', () => { + settings.if1 = { inPageLinks: true } + + moveToAnchor('if1', 'section-1') + + expect(trigger).toHaveBeenCalledWith( + 'Move to anchor', + 'moveToAnchor:section-1', + 'if1', + ) + }) + + test('throws when inPageLinks is not enabled', () => { + settings.if1 = { inPageLinks: false } + + expect(() => moveToAnchor('if1', 'section-1')).toThrow(/inPageLinks.*true/) + expect(trigger).not.toHaveBeenCalled() + }) + + test('throws when settings entry is missing', () => { + expect(() => moveToAnchor('missing', 'section-1')).toThrow( + /inPageLinks.*true/, + ) + expect(trigger).not.toHaveBeenCalled() + }) + + test('throws TypeError when anchor is not a string', () => { + settings.if1 = { inPageLinks: true } + + // @ts-expect-error testing runtime type check + expect(() => moveToAnchor('if1', 123)).toThrow(TypeError) + expect(trigger).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/methods/move-to-anchor.ts b/packages/core/methods/move-to-anchor.ts new file mode 100644 index 000000000..883195bba --- /dev/null +++ b/packages/core/methods/move-to-anchor.ts @@ -0,0 +1,17 @@ +import { typeAssert } from '@iframe-resizer/common' +import { STRING } from '@iframe-resizer/common/consts' + +import trigger from '../send/trigger' +import settings from '../values/settings' + +export default function moveToAnchor(id: string, anchor: string): void { + typeAssert(anchor, STRING, 'moveToAnchor(anchor) anchor') + + if (settings[id]?.inPageLinks !== true) { + throw new Error( + 'moveToAnchor() requires the "inPageLinks" option to be set to true', + ) + } + + trigger('Move to anchor', `moveToAnchor:${anchor}`, id) +} diff --git a/packages/core/setup/direction.test.ts b/packages/core/setup/direction.test.ts index d10a813e7..ca96f4fe4 100644 --- a/packages/core/setup/direction.test.ts +++ b/packages/core/setup/direction.test.ts @@ -19,11 +19,26 @@ describe('core/setup/direction', () => { } }) - test('vertical leaves sizes as-is', () => { + test('vertical resets to default sizes', () => { settings.i7.direction = VERTICAL setDirection('i7') expect(settings.i7.sizeHeight).toBe(true) + expect(settings.i7.sizeWidth).toBe(false) + expect(settings.i7.autoResize).toBe(true) + }) + + test('switching back to vertical clears stale flags', () => { + settings.i7.direction = HORIZONTAL + setDirection('i7') + expect(settings.i7.sizeWidth).toBe(true) + expect(settings.i7.sizeHeight).toBe(false) + + settings.i7.direction = VERTICAL + setDirection('i7') + expect(settings.i7.sizeWidth).toBe(false) + expect(settings.i7.sizeHeight).toBe(true) + expect(settings.i7.autoResize).toBe(true) }) test('horizontal sets sizeWidth true and sizeHeight false via fallthrough', () => { diff --git a/packages/core/setup/direction.ts b/packages/core/setup/direction.ts index 771470993..7d3c398de 100644 --- a/packages/core/setup/direction.ts +++ b/packages/core/setup/direction.ts @@ -2,11 +2,18 @@ import { BOTH, HORIZONTAL, NONE, VERTICAL } from '@iframe-resizer/common/consts' import { HIGHLIGHT } from 'auto-console-group' import { log } from '../console' +import defaults from '../values/defaults' import settings from '../values/settings' export default function setDirection(id: string): void { const { direction } = settings[id] + // Reset to defaults first so re-running on option update doesn't leave + // stale flags from a previous direction. + settings[id].sizeWidth = defaults.sizeWidth + settings[id].sizeHeight = defaults.sizeHeight + settings[id].autoResize = defaults.autoResize + switch (direction) { case VERTICAL: break diff --git a/packages/core/setup/has-mouse-events.ts b/packages/core/setup/has-mouse-events.ts new file mode 100644 index 000000000..5c3c270a1 --- /dev/null +++ b/packages/core/setup/has-mouse-events.ts @@ -0,0 +1,5 @@ +const hasMouseEvents = (options: Record): boolean => + typeof options.onMouseEnter === 'function' || + typeof options.onMouseLeave === 'function' + +export default hasMouseEvents diff --git a/packages/core/setup/normalize-log.ts b/packages/core/setup/normalize-log.ts new file mode 100644 index 000000000..d9e2507d6 --- /dev/null +++ b/packages/core/setup/normalize-log.ts @@ -0,0 +1,42 @@ +import { hasOwn, isString } from '@iframe-resizer/common' +import { + COLLAPSE, + EXPAND, + LOG_COLLAPSED, + LOG_DISABLED, + LOG_EXPANDED, +} from '@iframe-resizer/common/consts' + +// Normalize the IFrameLogOption value: numeric levels (LOG_DISABLED=0, +// LOG_COLLAPSED=1, LOG_EXPANDED=2) get mapped to their string equivalents, +// then any string is collapsed to log:true with logExpand derived from the +// string. Mutates `options` in place. No-op if `log` was not provided. +// Mirrors the handling in setup/logging.ts so update paths don't leak +// non-boolean values into settings.log. +export default function normalizeLog(options: Record): void { + if (!hasOwn(options, 'log')) return + + switch (options.log) { + case LOG_DISABLED: + options.log = false + break + + case LOG_COLLAPSED: + options.log = COLLAPSE + break + + case LOG_EXPANDED: + options.log = EXPAND + break + + default: + break + } + + if (isString(options.log)) { + if (!hasOwn(options, 'logExpand')) { + options.logExpand = options.log === EXPAND + } + options.log = true + } +} diff --git a/packages/core/setup/process-options.ts b/packages/core/setup/process-options.ts index 9e9bd47b7..dbafc7e0d 100644 --- a/packages/core/setup/process-options.ts +++ b/packages/core/setup/process-options.ts @@ -1,4 +1,4 @@ -import { hasOwn, setMode } from '@iframe-resizer/common' +import { setMode } from '@iframe-resizer/common' import checkOptions from '../checks/options' import checkWarningTimeout from '../checks/warning-timeout' @@ -7,12 +7,10 @@ import setOffsetSize from '../send/offset' import defaults from '../values/defaults' import settings from '../values/settings' import setDirection from './direction' +import hasMouseEvents from './has-mouse-events' import { getPostMessageTarget, setTargetOrigin } from './target-origin' import updateOptionNames from './update-option-names' -const hasMouseEvents = (options: Record): boolean => - hasOwn(options, 'onMouseEnter') || hasOwn(options, 'onMouseLeave') - export default function processOptions( iframe: HTMLIFrameElement, options: Record, diff --git a/packages/core/setup/update.test.ts b/packages/core/setup/update.test.ts new file mode 100644 index 000000000..b9e80adee --- /dev/null +++ b/packages/core/setup/update.test.ts @@ -0,0 +1,165 @@ +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', () => ({ + advise: vi.fn(), + error: vi.fn(), + event: vi.fn(), + info: vi.fn(), + log: vi.fn(), + updateConsoleExpand: vi.fn(), +})) +vi.mock('./target-origin', () => ({ setTargetOrigin: vi.fn() })) +vi.mock('./scrolling', () => ({ default: 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 setScrolling = (await import('./scrolling')).default +const setOffsetSize = (await import('../send/offset')).default +const { updateConsoleExpand } = 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('throws RangeError and skips dispatch when child version < 6', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(false) + settings.edge1 = { mode: 0, direction: 'vertical' } + + expect(() => + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: true }), + ).toThrow(RangeError) + 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('re-applies side-effect setup steps', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + const iframe = { id: 'edge1' } as HTMLIFrameElement + updateIframe(iframe, { scrolling: false }) + + expect(setScrolling).toHaveBeenCalledWith(iframe) + expect(setOffsetSize).toHaveBeenCalledWith('edge1', { scrolling: false }) + expect(updateConsoleExpand).toHaveBeenCalledWith('edge1') + // setDirection ran and re-derived flags from the (vertical) direction + expect(settings.edge1.sizeWidth).toBe(false) + expect(settings.edge1.sizeHeight).toBe(true) + }) + + it('re-derives size flags when direction changes', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { + direction: 'horizontal', + }) + + expect(settings.edge1.sizeWidth).toBe(true) + expect(settings.edge1.sizeHeight).toBe(false) + }) + + it('translates deprecated option names', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + const onClose = () => {} + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { onClose }) + + expect(settings.edge1.onBeforeClose).toBe(onClose) + expect('onClose' in settings.edge1).toBe(false) + }) + + 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) + }) + + it('does not flip mouseEvents when mouse handler keys are present but undefined', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical', mouseEvents: false } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { + onMouseEnter: undefined, + onMouseLeave: undefined, + }) + + expect(settings.edge1.mouseEvents).toBe(false) + }) + + it('normalizes string log values into a boolean and derives logExpand', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: 'expanded' }) + + expect(settings.edge1.log).toBe(true) + expect(settings.edge1.logExpand).toBe(true) + }) + + it('normalizes numeric LOG_DISABLED (0) to false', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: 0 }) + + expect(settings.edge1.log).toBe(false) + }) + + it('normalizes numeric LOG_EXPANDED (2) to log:true and logExpand:true', () => { + vi.mocked(meetsMinChildVersion).mockReturnValueOnce(true) + settings.edge1 = { mode: 0, direction: 'vertical' } + + updateIframe({ id: 'edge1' } as HTMLIFrameElement, { log: 2 }) + + expect(settings.edge1.log).toBe(true) + expect(settings.edge1.logExpand).toBe(true) + }) +}) diff --git a/packages/core/setup/update.ts b/packages/core/setup/update.ts new file mode 100644 index 000000000..7b6afbc79 --- /dev/null +++ b/packages/core/setup/update.ts @@ -0,0 +1,60 @@ +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 { event as consoleEvent, log, updateConsoleExpand } from '../console' +import { checkTitle } from '../page/title' +import setOffsetSize from '../send/offset' +import createOutgoingMessage from '../send/outgoing' +import trigger from '../send/trigger' +import settings from '../values/settings' +import setDirection from './direction' +import hasMouseEvents from './has-mouse-events' +import normalizeLog from './normalize-log' +import setScrolling from './scrolling' +import { setTargetOrigin } from './target-origin' +import updateOptionNames from './update-option-names' + +function mergeOptions( + iframe: HTMLIFrameElement, + options: Record, +): void { + const { id } = iframe + + normalizeLog(options) + + settings[id] = { + ...settings[id], + ...checkOptions(id, options), + } + + if (hasMouseEvents(options)) settings[id].mouseEvents = true + if (hasOwn(options, 'mode')) settings[id].mode = setMode(options) + + updateOptionNames(id) + setDirection(id) + setScrolling(iframe) + setOffsetSize(id, options) + setTargetOrigin(id) + updateConsoleExpand(id) + settings[id].syncTitle = checkTitle(id) +} + +export default function updateIframe( + iframe: HTMLIFrameElement, + options: Record, +): void { + const { id } = iframe + consoleEvent(id, UPDATE) + + if (!meetsMinChildVersion(id)) { + throw new RangeError( + 'Updating options on a bound iframe requires @iframe-resizer/child v6 or later in the iframe.', + ) + } + + mergeOptions(iframe, 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..d0cc6729a 100644 --- a/packages/react/index.test.tsx +++ b/packages/react/index.test.tsx @@ -7,16 +7,21 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import IframeResizer from './index' -// Mock auto-console-group to avoid noisy logs and to provide required API -vi.mock('auto-console-group', () => ({ - default: () => ({ +// Shared console-group spy so tests can assert on its calls +const { consoleGroup } = vi.hoisted(() => ({ + consoleGroup: { label: vi.fn(), event: vi.fn(), warn: vi.fn(), expand: vi.fn(), log: vi.fn(), endAutoGroup: vi.fn(), - }), + }, +})) + +// Mock auto-console-group to avoid noisy logs and to provide required API +vi.mock('auto-console-group', () => ({ + default: () => consoleGroup, })) // Mock connectResizer to attach a minimal iframeResizer API and return a resizer @@ -49,6 +54,7 @@ describe('React IframeResizer component', () => { disconnect.mockClear() moveToAnchor.mockClear() sendMessage.mockClear() + consoleGroup.expand.mockClear() }) test('renders an iframe and wires ref methods', async () => { @@ -140,6 +146,83 @@ 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('expands the console group when log="expanded"', async () => { + await act(async () => { + root.render( + , + ) + await Promise.resolve() + }) + + expect(consoleGroup.expand).toHaveBeenCalledWith(true) + + await act(async () => { + root.unmount() + }) + }) + + test('does not expand the console group for a plain log flag', async () => { + await act(async () => { + root.render( + , + ) + await Promise.resolve() + }) + + expect(consoleGroup.expand).toHaveBeenCalledWith(false) + + 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..ea7daf0ef 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -1,6 +1,7 @@ // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable react/require-default-props */ import { esModuleInterop } from '@iframe-resizer/common' +import { EXPAND, LOG_EXPANDED } from '@iframe-resizer/common/consts' import type { IFrameComponent } from '@iframe-resizer/core' import connectResizer from '@iframe-resizer/core' import acg from 'auto-console-group' @@ -13,6 +14,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' @@ -24,7 +26,7 @@ function IframeResizer( props: IFrameResizerProps, ref: React.ForwardedRef, ): ReactElement { - const { log, logExpand } = props + const { log } = props const filteredProps = filterIframeAttribs(props) const iframeRef = useRef(null) const consoleGroupRef = @@ -45,17 +47,17 @@ 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 consoleGroup.label(`react(${iframe.id})`) consoleGroup.event('setup') - const resizer = connectResizer({ ...props, onBeforeClose })(iframe) + const { logExpand: _logExpand, ...coreProps } = props + const resizer = connectResizer({ ...coreProps, onBeforeClose })(iframe) - consoleGroup.expand(logExpand) + consoleGroup.expand(log === EXPAND || log === LOG_EXPANDED) if (log) consoleGroup.log('Created React component') return () => { @@ -64,6 +66,34 @@ 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. + // + // optionsKey is the only meaningful dep — it's a stable serialization of the + // props the resizer cares about. We read latest `props` and `onBeforeClose` + // through refs so the effect doesn't re-fire on unrelated re-renders. + const optionsKey = buildOptionsKey(props) + const propsRef = useRef(props) + const onBeforeCloseRef = useRef(onBeforeClose) + propsRef.current = props + onBeforeCloseRef.current = onBeforeClose + + const isFirstUpdateRef = useRef(true) + useEffect(() => { + if (isFirstUpdateRef.current) { + isFirstUpdateRef.current = false + return + } + const iframe = iframeRef.current + if (!iframe) return + const { logExpand: _logExpand, ...coreProps } = propsRef.current + connectResizer({ + ...coreProps, + onBeforeClose: onBeforeCloseRef.current, + })(iframe) + }, [optionsKey]) + useImperativeHandle( ref, () => ({ diff --git a/packages/react/options-key.ts b/packages/react/options-key.ts new file mode 100644 index 000000000..e5e286aec --- /dev/null +++ b/packages/react/options-key.ts @@ -0,0 +1,41 @@ +// 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, + bodyBackground, + bodyMargin, + bodyPadding, + checkOrigin, + direction, + inPageLinks, + offsetSize, + scrolling, + tolerance, + waitForLoad, + warningTimeout, + onMouseEnter, + onMouseLeave, +}: Record): string => + JSON.stringify({ + id, + license, + log, + 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..d7a363680 100644 --- a/packages/solid/IframeResizer.tsx +++ b/packages/solid/IframeResizer.tsx @@ -1,11 +1,13 @@ import { esModuleInterop } from '@iframe-resizer/common' +import { EXPAND, LOG_EXPANDED } from '@iframe-resizer/common/consts' 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 +42,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 +56,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 +77,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 === EXPAND || local.log === LOG_EXPANDED) + if (local.log) consoleGroup.log('Created Solid component') if (typeof setRef === 'function') { setRef({ @@ -120,6 +95,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