From 27a7fa2773b9fb952913aa98a448165522c97e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Wed, 23 Sep 2026 22:19:43 -0500 Subject: [PATCH 1/9] refactor(sanity): adopt Angular 20 rendering and binding APIs --- .../playwright.config.ts | 2 +- .../tests/dynamic-updates.component.spec.ts | 30 ++++ .../src/live-query-provider.component.spec.ts | 40 +++++ .../src/live-query-provider.component.ts | 13 -- .../src/ui/element-overlay.component.spec.ts | 94 ++++++++++ .../src/ui/element-overlay.component.ts | 166 +++++++----------- .../src/ui/overlays.component.ts | 26 ++- .../src/ui/render-overlay-component.spec.ts | 49 ++++++ .../src/ui/render-overlay-component.ts | 34 ++++ tools/angular-compat/test-consumer.mjs | 43 ++++- 10 files changed, 367 insertions(+), 130 deletions(-) create mode 100644 packages/sanity/preview-kit/src/live-query-provider.component.spec.ts create mode 100644 packages/sanity/visual-editing/src/ui/element-overlay.component.spec.ts create mode 100644 packages/sanity/visual-editing/src/ui/render-overlay-component.spec.ts create mode 100644 packages/sanity/visual-editing/src/ui/render-overlay-component.ts diff --git a/apps/sanity-presentation-e2e/playwright.config.ts b/apps/sanity-presentation-e2e/playwright.config.ts index 4801f00..1bb445f 100644 --- a/apps/sanity-presentation-e2e/playwright.config.ts +++ b/apps/sanity-presentation-e2e/playwright.config.ts @@ -33,7 +33,7 @@ const storageStatePath = ? undefined : existingStorageStatePath(requestedStorageStatePath); const browserChannel = - (isAuthSetup ? optionalEnv('SANITY_E2E_BROWSER_CHANNEL') : undefined) ?? + optionalEnv('SANITY_E2E_BROWSER_CHANNEL') ?? (isAuthSetup && !cdpEndpoint && !process.env['CI'] ? 'chrome' : undefined); const browserProjectName = cdpEndpoint ? 'cdp' : (browserChannel ?? 'chromium'); const desktopChromeUse = { diff --git a/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts b/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts index 7016937..63b54f6 100644 --- a/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts +++ b/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts @@ -1,12 +1,42 @@ import { expect, test, describe, vi } from 'vitest'; +import { ChangeDetectorRef, Component, inject } from '@angular/core'; import { aliasedInput, render, screen } from '@testing-library/angular'; import { PortableTextComponent } from '../components/portable-text.component'; +import { PortableTextTypeComponent } from '../directives/portable-text-directives'; import * as fixtures from './fixtures'; import { HighlightComponent } from './test-components/HighlightComponent'; import { CodeComponent } from './test-components/CodeComponent'; import { assertHTML } from './helpers'; +@Component({ template: '{{ label }}' }) +class MutableBlock extends PortableTextTypeComponent { + static latest: MutableBlock; + label = 'before'; + readonly changeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + super(); + MutableBlock.latest = this; + } +} + describe('PortableText Dynamic Updates', () => { + test('updates a consumer component when it notifies change detection', async () => { + const { fixture } = await render(PortableTextComponent, { + inputs: { + value: fixtures.customBlockType.input, + ...aliasedInput('components', { types: { code: MutableBlock } }), + }, + }); + + expect(fixture.nativeElement.textContent).toContain('before'); + MutableBlock.latest.label = 'after'; + MutableBlock.latest.changeDetectorRef.markForCheck(); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('after'); + }); + test('updates rendered content when input changes', async () => { // Render with initial content const { rerender, container } = await render(PortableTextComponent, { diff --git a/packages/sanity/preview-kit/src/live-query-provider.component.spec.ts b/packages/sanity/preview-kit/src/live-query-provider.component.spec.ts new file mode 100644 index 0000000..d7fe912 --- /dev/null +++ b/packages/sanity/preview-kit/src/live-query-provider.component.spec.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; + +import { LivePreviewService } from './live-preview.service'; +import { LiveQueryProviderComponent } from './live-query-provider.component'; + +describe('LiveQueryProviderComponent', () => { + it('initializes once and updates perspective when its input changes', async () => { + const service = { + isInitialized: false, + initialize: vi.fn(function (this: typeof service) { + this.isInitialized = true; + }), + setPerspective: vi.fn(), + }; + const logger = { log: vi.fn() }; + + TestBed.configureTestingModule({ imports: [LiveQueryProviderComponent] }); + TestBed.overrideComponent(LiveQueryProviderComponent, { + set: { providers: [{ provide: LivePreviewService, useValue: service }] }, + }); + + const fixture = TestBed.createComponent(LiveQueryProviderComponent); + fixture.componentRef.setInput('token', 'test-token'); + fixture.componentRef.setInput('logger', logger); + fixture.componentRef.setInput('perspective', 'drafts'); + await fixture.whenStable(); + + expect(service.initialize).toHaveBeenCalledExactlyOnceWith('test-token'); + expect(service.setPerspective).toHaveBeenCalledExactlyOnceWith('drafts'); + expect(logger.log).toHaveBeenCalledOnce(); + + fixture.componentRef.setInput('perspective', 'published'); + await fixture.whenStable(); + + expect(service.initialize).toHaveBeenCalledTimes(1); + expect(service.setPerspective).toHaveBeenLastCalledWith('published'); + fixture.destroy(); + }); +}); diff --git a/packages/sanity/preview-kit/src/live-query-provider.component.ts b/packages/sanity/preview-kit/src/live-query-provider.component.ts index f4a42ca..c43dbcd 100644 --- a/packages/sanity/preview-kit/src/live-query-provider.component.ts +++ b/packages/sanity/preview-kit/src/live-query-provider.component.ts @@ -1,5 +1,4 @@ import { - afterNextRender, ChangeDetectionStrategy, Component, effect, @@ -35,18 +34,6 @@ export class LiveQueryProviderComponent { private livePreviewService = inject(LivePreviewService); constructor() { - // Initialization for Angular v18 - afterNextRender(() => { - if (!this.livePreviewService.isInitialized) { - this.initializeLivePreview( - this.token(), - this.perspective(), - this.logger(), - ); - } - }); - - // Initialization for Angular v19 effect(() => { const token = this.token(); const logger = this.logger(); diff --git a/packages/sanity/visual-editing/src/ui/element-overlay.component.spec.ts b/packages/sanity/visual-editing/src/ui/element-overlay.component.spec.ts new file mode 100644 index 0000000..c835b22 --- /dev/null +++ b/packages/sanity/visual-editing/src/ui/element-overlay.component.spec.ts @@ -0,0 +1,94 @@ +import { signal, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ElementOverlayComponent } from './element-overlay.component'; +import { PreviewSnapshotsService } from './preview/preview-snapshots.service'; +import { SchemaService } from './schema/schema.service'; + +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +afterEach(() => { + vi.unstubAllGlobals(); + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); + } +}); + +describe('ElementOverlayComponent', () => { + it('observes and scrolls after rendering, then closes its menu when hover ends', async () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + vi.stubGlobal( + 'IntersectionObserver', + class { + observe = observe; + disconnect = disconnect; + }, + ); + + TestBed.configureTestingModule({ + imports: [ElementOverlayComponent], + providers: [ + { + provide: PreviewSnapshotsService, + useValue: { snapshots: signal([]) }, + }, + { provide: SchemaService, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(ElementOverlayComponent); + const inputs = { + id: 'overlay-1', + draggable: false, + element: document.createElement('div'), + elementType: 'group', + inFrame: false, + enableScrollIntoView: true, + focused: false, + hovered: false, + isDragging: false, + node: { href: 'https://example.com' }, + rect: { x: 0, y: 0, w: 50, h: 30 }, + showActions: false, + targets: [], + wasMaybeCollapsed: false, + }; + for (const [name, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(name, value); + } + await fixture.whenStable(); + + fixture.componentRef.setInput('hovered', true); + fixture.componentRef.setInput('focused', true); + await fixture.whenStable(); + + expect(observe).toHaveBeenCalledOnce(); + expect(scrollIntoView).toHaveBeenCalledOnce(); + + const menuOpen = ( + fixture.componentInstance as unknown as { + menuOpen: WritableSignal; + } + ).menuOpen; + menuOpen.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.root').dataset.menuOpen).toBe( + '', + ); + + fixture.componentRef.setInput('hovered', false); + await fixture.whenStable(); + + expect(disconnect).toHaveBeenCalledOnce(); + expect(menuOpen()).toBe(false); + expect(fixture.nativeElement.querySelector('.root').dataset.menuOpen).toBe( + undefined, + ); + fixture.destroy(); + }); +}); diff --git a/packages/sanity/visual-editing/src/ui/element-overlay.component.ts b/packages/sanity/visual-editing/src/ui/element-overlay.component.ts index b11cae3..4957ea8 100644 --- a/packages/sanity/visual-editing/src/ui/element-overlay.component.ts +++ b/packages/sanity/visual-editing/src/ui/element-overlay.component.ts @@ -1,7 +1,7 @@ import { + afterRenderEffect, ChangeDetectionStrategy, Component, - type ComponentRef, ElementRef, EnvironmentInjector, Injector, @@ -10,6 +10,7 @@ import { effect, inject, input, + linkedSignal, signal, untracked, viewChild, @@ -26,7 +27,6 @@ import type { ElementFocusedState, ElementNode, OverlayComponentResolverContext, - OverlayPluginComponent, OverlayPluginDefinition, OverlayPluginExclusiveDefinition, OverlayPluginHudDefinition, @@ -38,6 +38,7 @@ import type { import { getLinkHref } from '../util/get-link-href'; import { PreviewSnapshotsService } from './preview/preview-snapshots.service'; import { SchemaService } from './schema/schema.service'; +import { renderOverlayComponent } from './render-overlay-component'; function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -94,24 +95,6 @@ function normalizeComponentDefinitions( return definitions; } -type OverlayComponentType = AngularOverlayComponent | OverlayPluginComponent; - -type AngularComponentWithInputMetadata = OverlayComponentType & { - ɵcmp?: { - inputs?: Record; - }; -}; - -function hasDeclaredInput( - component: OverlayComponentType, - input: string, -): boolean { - const inputMetadata = (component as AngularComponentWithInputMetadata).ɵcmp - ?.inputs; - - return !!inputMetadata && input in inputMetadata; -} - interface NodePluginCollection { id: string; context: OverlayComponentResolverContext; @@ -570,7 +553,10 @@ export class ElementOverlayComponent { protected isNearTop = signal(false); protected currentHref = signal(window.location.href); - protected menuOpen = signal(false); + protected menuOpen = linkedSignal({ + source: this.hovered, + computation: () => false, + }); protected activeExclusivePlugin = signal( undefined, ); @@ -748,51 +734,49 @@ export class ElementOverlayComponent { }); }); - effect((onCleanup) => { - const overlayElement = this.overlayElement()?.nativeElement; - const hovered = this.hovered(); + afterRenderEffect({ + read: (onCleanup) => { + const overlayElement = this.overlayElement()?.nativeElement; + const hovered = this.hovered(); - if (!overlayElement || !hovered) { - this.isNearTop.set(false); - return; - } + if (!overlayElement || !hovered) { + this.isNearTop.set(false); + return; + } - const observer = new IntersectionObserver( - ([intersection]) => { - this.isNearTop.set(intersection.boundingClientRect.top < 0); - }, - { threshold: 1 }, - ); - observer.observe(overlayElement); + const observer = new IntersectionObserver( + ([intersection]) => { + this.isNearTop.set(intersection.boundingClientRect.top < 0); + }, + { threshold: 1 }, + ); + observer.observe(overlayElement); - onCleanup(() => observer.disconnect()); + onCleanup(() => observer.disconnect()); + }, }); - effect(() => { - const overlayElement = this.overlayElement()?.nativeElement; - const focused = this.focused() === true; - - if ( - overlayElement && - !this.scrolledIntoView && - !this.wasMaybeCollapsed() && - focused && - this.enableScrollIntoView() - ) { - overlayElement.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest', - }); - } - - this.scrolledIntoView = focused; - }); + afterRenderEffect({ + write: () => { + const overlayElement = this.overlayElement()?.nativeElement; + const focused = this.focused() === true; + + if ( + overlayElement && + !this.scrolledIntoView && + !this.wasMaybeCollapsed() && + focused && + this.enableScrollIntoView() + ) { + overlayElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + } - effect(() => { - if (!this.hovered()) { - this.menuOpen.set(false); - } + this.scrolledIntoView = focused; + }, }); effect((onCleanup) => { @@ -823,13 +807,6 @@ export class ElementOverlayComponent { continue; } - const componentRef = viewContainer.createComponent( - definition.component, - { - environmentInjector: this.environmentInjector, - injector: this.injector, - }, - ); const values = { ...context, ...(definition.props ?? {}), @@ -837,7 +814,13 @@ export class ElementOverlayComponent { PointerEvents: VisualEditingPointerEventsComponent, }; - this.setComponentValues(componentRef, definition.component, values); + renderOverlayComponent( + viewContainer, + definition.component, + this.environmentInjector, + this.injector, + values, + ); } onCleanup(() => { @@ -863,13 +846,13 @@ export class ElementOverlayComponent { continue; } - const componentRef = viewContainer.createComponent(hud.component, { - environmentInjector: this.environmentInjector, - injector: this.injector, - }); - this.setComponentValues(componentRef, hud.component, { - ...collection.context, - }); + renderOverlayComponent( + viewContainer, + hud.component, + this.environmentInjector, + this.injector, + { ...collection.context }, + ); } } @@ -891,31 +874,18 @@ export class ElementOverlayComponent { } const component = activeExclusivePlugin.plugin.component; - const componentRef = viewContainer.createComponent(component, { - environmentInjector: this.environmentInjector, - injector: this.injector, - }); - - this.setComponentValues(componentRef, component, { - ...activeExclusivePlugin.context, - closeExclusiveView: () => this.closeExclusivePluginView(), - }); + renderOverlayComponent( + viewContainer, + component, + this.environmentInjector, + this.injector, + { + ...activeExclusivePlugin.context, + closeExclusiveView: () => this.closeExclusivePluginView(), + }, + ); onCleanup(() => viewContainer.clear()); }); } - - private setComponentValues( - componentRef: ComponentRef, - component: OverlayComponentType, - values: Record, - ): void { - for (const [key, value] of Object.entries(values)) { - if (hasDeclaredInput(component, key)) { - componentRef.setInput(key, value); - } else { - Reflect.set(componentRef.instance as object, key, value); - } - } - } } diff --git a/packages/sanity/visual-editing/src/ui/overlays.component.ts b/packages/sanity/visual-editing/src/ui/overlays.component.ts index 3361aea..9d32d04 100644 --- a/packages/sanity/visual-editing/src/ui/overlays.component.ts +++ b/packages/sanity/visual-editing/src/ui/overlays.component.ts @@ -115,6 +115,7 @@ function targetsLink(target: EventTarget | null): boolean { TelemetryService, ], template: ` + @let state = overlayState();
- @if (overlayState().contextMenu; as contextMenu) { + @if (state.contextMenu; as contextMenu) { } - @if (overlayState().isDragging && !overlayState().dragMinimapTransition) { - @if (overlayState().dragInsertPosition; as dragInsertPosition) { + @if (state.isDragging && !state.dragMinimapTransition) { + @if (state.dragInsertPosition; as dragInsertPosition) { } - @if (overlayState().dragShowMinimapPrompt) { + @if (state.dragShowMinimapPrompt) { } - @if (overlayState().dragGroupRect; as dragGroupRect) { + @if (state.dragGroupRect; as dragGroupRect) { } } - @if ( - overlayState().isDragging && overlayState().dragSkeleton; - as skeleton - ) { + @if (state.isDragging && state.dragSkeleton; as skeleton) { }
diff --git a/packages/sanity/visual-editing/src/ui/render-overlay-component.spec.ts b/packages/sanity/visual-editing/src/ui/render-overlay-component.spec.ts new file mode 100644 index 0000000..5d1d390 --- /dev/null +++ b/packages/sanity/visual-editing/src/ui/render-overlay-component.spec.ts @@ -0,0 +1,49 @@ +import { + Component, + EnvironmentInjector, + Injector, + ViewContainerRef, + input, + viewChild, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; + +import { renderOverlayComponent } from './render-overlay-component'; + +@Component({ template: '' }) +class TestHostComponent { + container = viewChild.required('container', { read: ViewContainerRef }); +} + +@Component({ template: '

{{ title() }} {{ legacy }}

' }) +class TestConsumerComponent { + // Exercise Angular's public input name when it differs from the class property. + // eslint-disable-next-line @angular-eslint/no-input-rename + title = input.required({ alias: 'heading' }); + legacy = ''; +} + +describe('renderOverlayComponent', () => { + it('binds public input names and preserves ordinary component properties', async () => { + TestBed.configureTestingModule({ + imports: [TestHostComponent, TestConsumerComponent], + }); + const fixture = TestBed.createComponent(TestHostComponent); + await fixture.whenStable(); + + renderOverlayComponent( + fixture.componentInstance.container(), + TestConsumerComponent, + TestBed.inject(EnvironmentInjector), + TestBed.inject(Injector), + { heading: 'Input value', legacy: 'Property value' }, + ); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain( + 'Input value Property value', + ); + fixture.destroy(); + }); +}); diff --git a/packages/sanity/visual-editing/src/ui/render-overlay-component.ts b/packages/sanity/visual-editing/src/ui/render-overlay-component.ts new file mode 100644 index 0000000..d287d46 --- /dev/null +++ b/packages/sanity/visual-editing/src/ui/render-overlay-component.ts @@ -0,0 +1,34 @@ +import { + inputBinding, + reflectComponentType, + type EnvironmentInjector, + type Injector, + type Type, + type ViewContainerRef, +} from '@angular/core'; + +export function renderOverlayComponent( + container: ViewContainerRef, + component: Type, + environmentInjector: EnvironmentInjector, + injector: Injector, + values: Record, +): void { + const inputs = new Set( + reflectComponentType(component)?.inputs.map((input) => input.templateName), + ); + const entries = Object.entries(values); + const componentRef = container.createComponent(component, { + environmentInjector, + injector, + bindings: entries + .filter(([name]) => inputs.has(name)) + .map(([name, value]) => inputBinding(name, () => value)), + }); + + for (const [name, value] of entries) { + if (!inputs.has(name)) { + Reflect.set(componentRef.instance as object, name, value); + } + } +} diff --git a/tools/angular-compat/test-consumer.mjs b/tools/angular-compat/test-consumer.mjs index bba521d..16b7f8c 100644 --- a/tools/angular-compat/test-consumer.mjs +++ b/tools/angular-compat/test-consumer.mjs @@ -277,7 +277,7 @@ function consumerMainSource(toolchain) { toolchain.angularMajor >= 20 ? 'provideZonelessChangeDetection' : 'provideExperimentalZonelessChangeDetection'; - const coreImports = ['Component', zonelessProvider]; + const coreImports = ['Component', 'signal', zonelessProvider]; const browserGlobalErrorProvider = toolchain.angularMajor >= 20 ? '\n provideBrowserGlobalErrorListeners(),' @@ -294,6 +294,7 @@ import { provideSanity } from '@limitless-angular/sanity'; import { SanityImage, provideSanityLoader } from '@limitless-angular/sanity/image-loader'; import { PortableTextComponent, + PortableTextTypeComponent, type PortableTextComponents, toPlainText, } from '@limitless-angular/sanity/portabletext'; @@ -325,6 +326,20 @@ const sanityConfig = { dataset: 'production', }; +@Component({ + selector: 'compat-custom-block', + template: '{{ label() }}', +}) +class CompatCustomBlock extends PortableTextTypeComponent { + readonly label = signal('before'); + + constructor() { + super(); + (window as Window & { updateCompatCustomBlock?: () => void }).updateCompatCustomBlock = + () => this.label.set('after'); + } +} + @Component({ selector: 'app-root', standalone: true, @@ -345,6 +360,11 @@ const sanityConfig = { [value]="blocks" [components]="components" > +
Compatibility = {}; + protected readonly customBlock = { _type: 'compat-custom', _key: 'custom' }; + protected readonly customComponents: Partial = { + types: { 'compat-custom': CompatCustomBlock }, + }; protected readonly image = 'image-abc123-120x80-png'; protected readonly insertMenuNode = {} as SchemaUnionNode; protected readonly overlayElement = {} as ElementNode; @@ -594,7 +618,11 @@ async function runSmoke(url) { try { browser = await withTimeout( - chromium.launch(), + chromium.launch( + process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] + ? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] } + : undefined, + ), browserTimeout, 'launch Chromium', ); @@ -620,6 +648,10 @@ async function runSmoke(url) { .textContent({ timeout: assertionTimeout }), /Angular compatibility/, ); + const customBlock = page.getByTestId('compat-custom-block'); + assert.equal(await customBlock.textContent({ timeout: assertionTimeout }), 'before'); + await page.evaluate(() => window.updateCompatCustomBlock()); + await customBlock.getByText('after').waitFor({ timeout: assertionTimeout }); const image = page.getByTestId('compat-image'); await image.waitFor({ state: 'visible', timeout: assertionTimeout }); @@ -761,6 +793,13 @@ function readPlaywrightVersion() { } function installPlaywrightBrowserIfNeeded(workspace) { + if (process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH']) { + console.log( + 'Using preinstalled Chromium executable; skipping browser download.', + ); + return; + } + const browserPath = process.env['PLAYWRIGHT_BROWSERS_PATH']; if (browserPath && browserPath !== '0') { From 3a9f29f0f4e9034e28f7c793c70b713c0853d4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Wed, 23 Sep 2026 22:42:18 -0500 Subject: [PATCH 2/9] refactor(sanity): apply Angular 20 follow-up improvements --- .../app/pages/(blog)/home-page.component.ts | 6 +- .../src/sanity-image.directive.spec.ts | 35 +++++++++ .../src/sanity-image.directive.ts | 2 + .../src/components/portable-text.component.ts | 11 ++- .../preview-kit/src/create-live-data.spec.ts | 78 +++++++++++++++++++ packages/sanity/tsconfig.json | 1 + .../visual-editing-client.component.spec.ts | 49 ++++++++++++ .../src/visual-editing-client.component.ts | 37 ++++----- 8 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 packages/sanity/preview-kit/src/create-live-data.spec.ts create mode 100644 packages/sanity/visual-editing/src/visual-editing-client.component.spec.ts diff --git a/apps/analog-sanity-blog-example/src/app/pages/(blog)/home-page.component.ts b/apps/analog-sanity-blog-example/src/app/pages/(blog)/home-page.component.ts index b90d0ff..bee3f52 100644 --- a/apps/analog-sanity-blog-example/src/app/pages/(blog)/home-page.component.ts +++ b/apps/analog-sanity-blog-example/src/app/pages/(blog)/home-page.component.ts @@ -118,7 +118,11 @@ export class HeroPostComponent { > More Stories - + @defer (on immediate; hydrate on viewport) { + + } @placeholder { + + } } diff --git a/packages/sanity/image-loader/src/sanity-image.directive.spec.ts b/packages/sanity/image-loader/src/sanity-image.directive.spec.ts index b50a6e4..3540938 100644 --- a/packages/sanity/image-loader/src/sanity-image.directive.spec.ts +++ b/packages/sanity/image-loader/src/sanity-image.directive.spec.ts @@ -1,5 +1,7 @@ +import { IMAGE_LOADER, type ImageLoaderConfig } from '@angular/common'; import { Component, signal, viewChild } from '@angular/core'; import { render } from '@testing-library/angular'; +import { vi } from 'vitest'; import { SANITY_CONFIG } from '@limitless-angular/sanity/shared'; import { SanityImage } from './sanity-image.directive'; @@ -112,6 +114,39 @@ describe('SanityImage', () => { ); }); + it('uses the Sanity loader when no custom image loader is provided', async () => { + const { fixture } = await render(SanityImageHost, { + providers: [{ provide: SANITY_CONFIG, useValue: sanityConfig }], + }); + + await fixture.whenStable(); + + const image = fixture.nativeElement.querySelector( + 'img', + ) as HTMLImageElement; + expect(new URL(image.src).searchParams.get('auto')).toBe('format'); + }); + + it('preserves a custom image loader provided by the consumer', async () => { + const customLoader = vi.fn( + ({ src }: ImageLoaderConfig) => `${src}&custom=1`, + ); + const { fixture } = await render(SanityImageHost, { + providers: [ + { provide: SANITY_CONFIG, useValue: sanityConfig }, + { provide: IMAGE_LOADER, useValue: customLoader }, + ], + }); + + await fixture.whenStable(); + + const image = fixture.nativeElement.querySelector( + 'img', + ) as HTMLImageElement; + expect(customLoader).toHaveBeenCalled(); + expect(new URL(image.src).searchParams.get('custom')).toBe('1'); + }); + it('updates ngSrc when the bound Sanity image changes', async () => { const { fixture } = await render(SanityImageHost, { providers: [{ provide: SANITY_CONFIG, useValue: sanityConfig }], diff --git a/packages/sanity/image-loader/src/sanity-image.directive.ts b/packages/sanity/image-loader/src/sanity-image.directive.ts index 6f64e7c..340fafe 100644 --- a/packages/sanity/image-loader/src/sanity-image.directive.ts +++ b/packages/sanity/image-loader/src/sanity-image.directive.ts @@ -37,6 +37,8 @@ const imageUrlInputs = [ const staticNgOptimizedImageInputs = ['loaderParams', 'width', 'height']; +// Angular does not expose its default loader publicly. Keep this identity check +// until custom loader precedence has an explicit public contract. function getNoopImageLoader() { return ( IMAGE_LOADER.ɵprov as { diff --git a/packages/sanity/portabletext/src/components/portable-text.component.ts b/packages/sanity/portabletext/src/components/portable-text.component.ts index 321eff5..c1189ea 100644 --- a/packages/sanity/portabletext/src/components/portable-text.component.ts +++ b/packages/sanity/portabletext/src/components/portable-text.component.ts @@ -65,12 +65,11 @@ import { PORTABLE_TEXT_RENDERER_CONTEXT } from '../tokens'; /> - @if (resolveNode(node, index, isInline); as nodeRendering) { - - } + @let nodeRendering = resolveNode(node, index, isInline); + { + it('starts with initial data, streams updates, and switches queries', () => { + const initial = signal('first initial'); + const query = signal('first'); + const first = new Subject(); + const second = new Subject(); + const streams = { first, second }; + const service = { + listenLiveQuery: (_initial: string, key: string) => + streams[key as keyof typeof streams], + }; + TestBed.configureTestingModule({ + providers: [{ provide: LivePreviewService, useValue: service }], + }); + + const liveData = TestBed.runInInjectionContext(() => + createLiveData( + () => initial(), + () => ({ query: query() }), + ), + ); + + expect(liveData()).toBe('first initial'); + TestBed.tick(); + first.next('live first'); + expect(liveData()).toBe('live first'); + + initial.set('second initial'); + query.set('second'); + TestBed.tick(); + expect(liveData()).toBe('second initial'); + + first.next('stale update'); + expect(liveData()).toBe('second initial'); + second.next('live second'); + expect(liveData()).toBe('live second'); + }); + + it('combines multiple query streams after each has emitted', () => { + const post = new Subject(); + const settings = new Subject(); + const streams = { post, settings }; + const service = { + listenLiveQuery: (_initial: string, key: string) => + streams[key as keyof typeof streams], + }; + TestBed.configureTestingModule({ + providers: [{ provide: LivePreviewService, useValue: service }], + }); + + const liveData = TestBed.runInInjectionContext(() => + createLiveData( + () => ({ post: 'initial post', settings: 'initial settings' }), + () => ({ post: { query: 'post' }, settings: { query: 'settings' } }), + ), + ); + + expect(liveData()).toEqual({ + post: 'initial post', + settings: 'initial settings', + }); + TestBed.tick(); + post.next('live post'); + expect(liveData().post).toBe('initial post'); + settings.next('live settings'); + expect(liveData()).toEqual({ + post: 'live post', + settings: 'live settings', + }); + }); +}); diff --git a/packages/sanity/tsconfig.json b/packages/sanity/tsconfig.json index 9dada10..f64efe4 100644 --- a/packages/sanity/tsconfig.json +++ b/packages/sanity/tsconfig.json @@ -24,6 +24,7 @@ "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, "strictInputAccessModifiers": true, + "strictStandalone": true, "typeCheckHostBindings": true, "strictTemplates": true } diff --git a/packages/sanity/visual-editing/src/visual-editing-client.component.spec.ts b/packages/sanity/visual-editing/src/visual-editing-client.component.spec.ts new file mode 100644 index 0000000..249bdfe --- /dev/null +++ b/packages/sanity/visual-editing/src/visual-editing-client.component.spec.ts @@ -0,0 +1,49 @@ +import { Component, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { vi } from 'vitest'; + +import type { HistoryAdapterNavigate } from './types'; +import { VisualEditingClientComponent } from './visual-editing-client.component'; + +@Component({ template: '' }) +class EmptyRouteComponent {} + +describe('VisualEditingClientComponent', () => { + it('sends the new URL to visual editing after each navigation', async () => { + TestBed.configureTestingModule({ + imports: [VisualEditingClientComponent], + providers: [ + provideRouter([ + { path: 'first', component: EmptyRouteComponent }, + { path: 'second', component: EmptyRouteComponent }, + ]), + ], + }); + + const fixture = TestBed.createComponent(VisualEditingClientComponent); + const navigate = vi.fn(); + const component = fixture.componentInstance as unknown as { + navigate: WritableSignal; + }; + component.navigate.set(navigate); + TestBed.tick(); + + const router = TestBed.inject(Router); + await router.navigateByUrl('/first?preview=true#section'); + TestBed.tick(); + expect(navigate).toHaveBeenLastCalledWith({ + type: 'push', + url: '/first?preview=true#section', + }); + + await router.navigateByUrl('/second'); + TestBed.tick(); + expect(navigate).toHaveBeenLastCalledWith({ + type: 'push', + url: '/second', + }); + + fixture.destroy(); + }); +}); diff --git a/packages/sanity/visual-editing/src/visual-editing-client.component.ts b/packages/sanity/visual-editing/src/visual-editing-client.component.ts index df1f409..ab0d6f0 100644 --- a/packages/sanity/visual-editing/src/visual-editing-client.component.ts +++ b/packages/sanity/visual-editing/src/visual-editing-client.component.ts @@ -12,11 +12,11 @@ import { output, untracked, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { toSignal } from '@angular/core/rxjs-interop'; import { Location } from '@angular/common'; import { Router, NavigationEnd } from '@angular/router'; -import { filter } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import type { ClientPerspective } from '@sanity/client'; import { enableVisualEditing } from './ui/enable-visual-editing'; @@ -78,8 +78,18 @@ export class VisualEditingClientComponent { private applicationRef = inject(ApplicationRef); + private router = inject(Router); + + private routeUrl = toSignal( + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map(() => this.router.url), + ), + { initialValue: this.router.url }, + ); + private currentUrl = computed(() => { - const urlTree = this.router.parseUrl(this.router.url); + const urlTree = this.router.parseUrl(this.routeUrl()); const primaryPath = urlTree.root.children['primary']?.segments .map((segment) => segment.path) @@ -102,8 +112,6 @@ export class VisualEditingClientComponent { private injector = inject(Injector); - private router = inject(Router); - constructor() { effect((onCleanup) => { const components = this.components(); @@ -170,25 +178,6 @@ export class VisualEditingClientComponent { } }); }); - - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(), - ) - .subscribe(() => { - const currentNavigate = this.navigate(); - const url = this.currentUrl(); - - untracked(() => { - if (currentNavigate) { - currentNavigate({ - type: 'push', - url, - }); - } - }); - }); } private defaultRefresh: VisualEditingOptions['refresh'] = (payload) => { From 43b382456a305d96c4aecb17724480f3c3cfb57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Thu, 24 Sep 2026 00:05:41 -0500 Subject: [PATCH 3/9] fix(image-loader): preserve explicit fit transformations --- packages/sanity/image-loader/src/loader.ts | 3 ++- .../sanity/image-loader/src/sanity-image.directive.spec.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sanity/image-loader/src/loader.ts b/packages/sanity/image-loader/src/loader.ts index 2afb686..77fe119 100644 --- a/packages/sanity/image-loader/src/loader.ts +++ b/packages/sanity/image-loader/src/loader.ts @@ -23,7 +23,8 @@ export function sanityImageLoader(config?: SanityConfig | null) { url.searchParams.set('auto', 'format'); url.searchParams.set( 'fit', - url.searchParams.get('fit') || url.searchParams.has('h') ? 'min' : 'max', + url.searchParams.get('fit') || + (url.searchParams.has('h') ? 'min' : 'max'), ); if (width && url.searchParams.has('h') && url.searchParams.has('w')) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/sanity/image-loader/src/sanity-image.directive.spec.ts b/packages/sanity/image-loader/src/sanity-image.directive.spec.ts index 3540938..2ed8fad 100644 --- a/packages/sanity/image-loader/src/sanity-image.directive.spec.ts +++ b/packages/sanity/image-loader/src/sanity-image.directive.spec.ts @@ -125,6 +125,7 @@ describe('SanityImage', () => { 'img', ) as HTMLImageElement; expect(new URL(image.src).searchParams.get('auto')).toBe('format'); + expect(new URL(image.src).searchParams.get('fit')).toBe('min'); }); it('preserves a custom image loader provided by the consumer', async () => { @@ -178,6 +179,7 @@ describe('SanityImage', () => { expect(directive.loaderParams).toEqual({ fit: 'crop' }); expect(url.searchParams.get('fit')).toBe('crop'); + expect(renderedImageSearchParams(fixture).get('fit')).toBe('crop'); }); it('updates ngSrc when quality changes', async () => { From 55d14c5a3e6144a07577e5b1915a49009ad601d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Thu, 24 Sep 2026 00:05:41 -0500 Subject: [PATCH 4/9] chore(sanity): enforce Angular template diagnostics --- packages/sanity/tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/sanity/tsconfig.json b/packages/sanity/tsconfig.json index f64efe4..d05181b 100644 --- a/packages/sanity/tsconfig.json +++ b/packages/sanity/tsconfig.json @@ -22,6 +22,12 @@ "extends": "../../tsconfig.base.json", "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, + "extendedDiagnostics": { + "checks": { + "uninvokedTrackFunction": "error", + "unusedStandaloneImports": "error" + } + }, "strictInjectionParameters": true, "strictInputAccessModifiers": true, "strictStandalone": true, From 779e2e66ff7a352fb2326645726b9b40757c771c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Thu, 24 Sep 2026 00:19:50 -0500 Subject: [PATCH 5/9] refactor(portabletext): render child templates declaratively --- .../src/components/children.component.ts | 17 +++++++--- .../src/directives/render-node.directive.ts | 32 ------------------- .../tests/dynamic-updates.component.spec.ts | 20 ++++++++++++ tools/angular-compat/test-consumer.mjs | 15 +++++++-- 4 files changed, 45 insertions(+), 39 deletions(-) delete mode 100644 packages/sanity/portabletext/src/directives/render-node.directive.ts diff --git a/packages/sanity/portabletext/src/components/children.component.ts b/packages/sanity/portabletext/src/components/children.component.ts index 07ad0c1..5a32847 100644 --- a/packages/sanity/portabletext/src/components/children.component.ts +++ b/packages/sanity/portabletext/src/components/children.component.ts @@ -1,16 +1,18 @@ import { ChangeDetectionStrategy, Component, + inject, TemplateRef, viewChild, ViewEncapsulation, } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; import { TypedObject } from '@portabletext/types'; import { trackBy } from '../utils'; -import { RenderNode } from '../directives/render-node.directive'; +import { PORTABLE_TEXT_RENDERER_CONTEXT } from '../tokens'; @Component({ - imports: [RenderNode], + imports: [NgTemplateOutlet], template: ` @for ( child of children; @@ -18,9 +20,12 @@ import { RenderNode } from '../directives/render-node.directive'; let index = $index ) { } `, @@ -34,5 +39,7 @@ export class ChildrenComponent { isInline?: boolean; }> >(TemplateRef); + protected readonly renderNode = inject(PORTABLE_TEXT_RENDERER_CONTEXT) + .renderNode; protected readonly trackBy = trackBy; } diff --git a/packages/sanity/portabletext/src/directives/render-node.directive.ts b/packages/sanity/portabletext/src/directives/render-node.directive.ts deleted file mode 100644 index 8921e0a..0000000 --- a/packages/sanity/portabletext/src/directives/render-node.directive.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - Directive, - effect, - inject, - input, - ViewContainerRef, -} from '@angular/core'; - -import { TypedObject } from '@portabletext/types'; - -import { PORTABLE_TEXT_RENDERER_CONTEXT } from '../tokens'; - -// eslint-disable-next-line @angular-eslint/directive-selector -@Directive({ selector: '[renderNode]' }) -export class RenderNode { - node = input.required({ alias: 'renderNode' }); - index = input(); - isInline = input.required(); - #renderNode = inject(PORTABLE_TEXT_RENDERER_CONTEXT).renderNode; - #vcr = inject(ViewContainerRef); - - constructor() { - effect(() => { - this.#vcr.clear(); - this.#vcr.createEmbeddedView(this.#renderNode(), { - $implicit: this.node(), - isInline: this.isInline(), - index: this.index(), - }); - }); - } -} diff --git a/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts b/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts index 63b54f6..98a1ba5 100644 --- a/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts +++ b/packages/sanity/portabletext/src/tests/dynamic-updates.component.spec.ts @@ -37,6 +37,26 @@ describe('PortableText Dynamic Updates', () => { expect(fixture.nativeElement.textContent).toContain('after'); }); + test('preserves a keyed custom component when its node changes', async () => { + const { rerender } = await render(PortableTextComponent, { + inputs: { + value: fixtures.customBlockType.input, + ...aliasedInput('components', { types: { code: MutableBlock } }), + }, + }); + + const component = MutableBlock.latest; + await rerender({ + inputs: { + value: [{ ...fixtures.customBlockType.input[0], code: 'updated' }], + }, + partialUpdate: true, + }); + + expect(MutableBlock.latest).toBe(component); + expect(MutableBlock.latest.value().code).toBe('updated'); + }); + test('updates rendered content when input changes', async () => { // Render with initial content const { rerender, container } = await render(PortableTextComponent, { diff --git a/tools/angular-compat/test-consumer.mjs b/tools/angular-compat/test-consumer.mjs index 16b7f8c..d3c9b40 100644 --- a/tools/angular-compat/test-consumer.mjs +++ b/tools/angular-compat/test-consumer.mjs @@ -335,6 +335,8 @@ class CompatCustomBlock extends PortableTextTypeComponent { constructor() { super(); + const browser = window as Window & { compatCustomBlockCreations?: number }; + browser.compatCustomBlockCreations = (browser.compatCustomBlockCreations ?? 0) + 1; (window as Window & { updateCompatCustomBlock?: () => void }).updateCompatCustomBlock = () => this.label.set('after'); } @@ -362,7 +364,7 @@ class CompatCustomBlock extends PortableTextTypeComponent { >
= {}; - protected readonly customBlock = { _type: 'compat-custom', _key: 'custom' }; + protected readonly customBlock = signal({ _type: 'compat-custom', _key: 'custom' }); protected readonly customComponents: Partial = { types: { 'compat-custom': CompatCustomBlock }, }; @@ -400,6 +402,11 @@ class AppComponent { protected readonly overlayNode = {} as SanityNode; protected readonly overlayParent = this.insertMenuNode as OverlayElementParent; protected readonly plainText = toPlainText(blocks); + + constructor() { + (window as Window & { updateCompatCustomBlockNode?: () => void }).updateCompatCustomBlockNode = + () => this.customBlock.set({ ...this.customBlock() }); + } } bootstrapApplication(AppComponent, { @@ -652,6 +659,10 @@ async function runSmoke(url) { assert.equal(await customBlock.textContent({ timeout: assertionTimeout }), 'before'); await page.evaluate(() => window.updateCompatCustomBlock()); await customBlock.getByText('after').waitFor({ timeout: assertionTimeout }); + await page.evaluate(() => window.updateCompatCustomBlockNode()); + await page.waitForTimeout(50); + assert.equal(await customBlock.textContent({ timeout: assertionTimeout }), 'after'); + assert.equal(await page.evaluate(() => window.compatCustomBlockCreations), 1); const image = page.getByTestId('compat-image'); await image.waitFor({ state: 'visible', timeout: assertionTimeout }); From d096fd64bdda9341b35589c2316b50b75875387e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Andr=C3=A9s=20L=C3=B3pez=20Molina?= Date: Thu, 24 Sep 2026 00:19:50 -0500 Subject: [PATCH 6/9] refactor(visual-editing): reuse template signal values --- .../overlay-components/components/insert-menu.component.ts | 5 +++-- .../sanity/visual-editing/src/ui/visual-editing.component.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sanity/visual-editing/src/overlay-components/components/insert-menu.component.ts b/packages/sanity/visual-editing/src/overlay-components/components/insert-menu.component.ts index c85055f..ed12880 100644 --- a/packages/sanity/visual-editing/src/overlay-components/components/insert-menu.component.ts +++ b/packages/sanity/visual-editing/src/overlay-components/components/insert-menu.component.ts @@ -64,9 +64,10 @@ function getInsertMenuOptions( } - @if (filteredTypes().length) { + @let types = filteredTypes(); + @if (types.length) {
- @for (schemaType of filteredTypes(); track schemaType.name) { + @for (schemaType of types; track schemaType.name) {