From 28d1cf2bb9768bd9aa96f51d3260322dca9c7d13 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 4 Aug 2026 00:02:58 +0800 Subject: [PATCH 01/12] Add sharedEditor prop for EditContext component --- packages/diffs/src/react/EditContext.tsx | 34 ++++++++++++-- .../diffs/test/reactEditorOptions.test.ts | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/react/EditContext.tsx b/packages/diffs/src/react/EditContext.tsx index 827c01df2..495478773 100644 --- a/packages/diffs/src/react/EditContext.tsx +++ b/packages/diffs/src/react/EditContext.tsx @@ -14,8 +14,22 @@ export type CreateEditor = ( ) => DiffsEditor; export interface EditProviderProps { - /** Combines shared defaults with the supplied per-surface options. */ - createEditor: CreateEditor; + /** + * Creates an editor for each editable surface. Combines shared defaults + * with the supplied per-surface options. Ignored when `sharedEditor` is + * provided. + */ + createEditor?: CreateEditor; + /** + * One editor instance handed to every editable surface under this + * provider, exactly as its owner configured it — the surfaces' + * `editorOptions` props are ignored, so pass options to the editor's + * constructor instead. Instance state — like persist-state records — + * survives surface remounts. Only for UIs that edit one surface at a + * time; simultaneously editable surfaces (e.g. several CodeView items in + * edit mode) need per-surface editors from `createEditor`. + */ + sharedEditor?: DiffsEditor; } export const EditContext: Context | undefined> = @@ -24,8 +38,22 @@ export const EditContext: Context | undefined> = export function EditProvider({ children, createEditor, + sharedEditor, }: PropsWithChildren>): React.JSX.Element { - const stableCreateEditor = useStableCallback(createEditor); + const stableCreateEditor = useStableCallback( + (options: EditorOptions): DiffsEditor => { + if (sharedEditor != null) { + // options is ignored when sharedEditor is provided + return sharedEditor; + } + if (createEditor == null) { + throw new Error( + 'EditProvider: either `sharedEditor` or `createEditor` is required' + ); + } + return createEditor(options); + } + ); return ( {children} diff --git a/packages/diffs/test/reactEditorOptions.test.ts b/packages/diffs/test/reactEditorOptions.test.ts index e9c5e6774..170e11ecf 100644 --- a/packages/diffs/test/reactEditorOptions.test.ts +++ b/packages/diffs/test/reactEditorOptions.test.ts @@ -931,4 +931,50 @@ describe('React editor factory lifecycle', () => { cleanup(); } }); + + test('EditProvider hands surfaces the sharedEditor as configured, ignoring createEditor and surface editorOptions', async () => { + const { cleanup } = installDom(); + const cleanupActEnvironment = installReactActEnvironment(); + const container = document.createElement('div'); + document.body.appendChild(container); + // The shared editor is used exactly as constructed: its own onAttach must + // fire, while the surface's editorOptions (including its onAttach) are + // ignored entirely. + let attachedEditor: Editor | undefined; + const sharedEditor = new Editor({ + onAttach(editor) { + attachedEditor = editor; + }, + }); + const factory = mock((options: EditorOptions) => { + return new Editor(options); + }); + const surfaceOnAttach = mock((_editor: Editor) => {}); + let root: Root | undefined; + + try { + root = createReactRoot(container); + await act(async () => { + root!.render( + createElement( + EditProviderComponent, + { createEditor: factory, sharedEditor }, + createEditableSurfaceElement('File', true, { + onAttach: surfaceOnAttach, + }) + ) + ); + await wait(10); + }); + await waitFor(() => attachedEditor !== undefined); + + expect(attachedEditor).toBe(sharedEditor); + expect(factory).not.toHaveBeenCalled(); + expect(surfaceOnAttach).not.toHaveBeenCalled(); + } finally { + await unmountRoot(root); + cleanupActEnvironment(); + cleanup(); + } + }); }); From 500f620a028792a4c16c99022b8966075e98332e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 4 Aug 2026 00:25:59 +0800 Subject: [PATCH 02/12] store viewport scroll top --- packages/diffs/src/editor/editor.ts | 116 +++++++++++++++++----------- packages/diffs/src/editor/utils.ts | 27 +++++++ packages/diffs/src/types.ts | 2 + 3 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c703dd2e5..67373ae97 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -127,6 +127,7 @@ import { extend, getLineNumberAttr, h, + lookupScrollContainer, round, } from './utils'; @@ -595,6 +596,7 @@ export class Editor implements DiffsEditor { fileInstance != null ? { scrollLeft: fileInstance.getCodeScrollLeft(), + scrollTop: this.#getViewportScrollTop(), } : undefined, }; @@ -611,6 +613,11 @@ export class Editor implements DiffsEditor { // sits outside that viewport (e.g. TreeApp remount restore). if (view != null) { this.#fileInstance.setCodeScrollLeft(view.scrollLeft); + // Records persisted before scrollTop existed lack it: leave the + // viewport where it is rather than guessing. + if (view.scrollTop !== undefined) { + this.#setViewportScrollTop(view.scrollTop); + } return; } this.#scrollToPrimaryCaret(); @@ -730,7 +737,7 @@ export class Editor implements DiffsEditor { this.#attachState.delivered = false; } const hadFileInstance = this.#fileInstance != null; - const shouldRestoreState = this.#isStatePersistenceEnabled; + const shouldRestoreState = this.#options.persistState === true; this.#stateRestoreGeneration++; this.#persistCurrentState(); if (hadFileInstance) { @@ -933,9 +940,10 @@ export class Editor implements DiffsEditor { this.#fileInfo.name !== fileOrDiff.name || this.#fileInfo.lang !== fileOrDiff.lang || this.#fileInfo.cacheKey !== fileOrDiff.cacheKey; - const persistedCacheKey = this.#isStatePersistenceEnabled - ? requirePersistedCacheKey(fileOrDiff) - : undefined; + const persistedCacheKey = + this.#options.persistState === true + ? requirePersistedCacheKey(fileOrDiff) + : undefined; let persistedStateTarget: | { cacheKey: string; textDocument: TextDocument } @@ -1157,12 +1165,6 @@ export class Editor implements DiffsEditor { this.#scheduleOnAttach(fileInstance); }; - get #isStatePersistenceEnabled(): boolean { - return ( - this.#options.persistState === true && this.#fileInstance?.type === 'file' - ); - } - #getCachedTextDocument( file: FileContents | FileDiffMetadata, cacheKey: string @@ -1188,7 +1190,11 @@ export class Editor implements DiffsEditor { const fileInfo = this.#fileInfo; const textDocument = this.#textDocument; if ( - !this.#isStatePersistenceEnabled || + this.#options.persistState !== true || + // Requires an attached instance: a repeated cleanUp still holds the + // retained fileInfo but must not write (empty) state over the record + // the first cleanUp persisted. + this.#fileInstance === undefined || fileInfo === undefined || textDocument === undefined ) { @@ -1214,7 +1220,8 @@ export class Editor implements DiffsEditor { if ( textDocument.version === pendingRestore.documentVersion && this.#selections === pendingRestore.selections && - state.view?.scrollLeft === pendingRestore.view?.scrollLeft + state.view?.scrollLeft === pendingRestore.view?.scrollLeft && + state.view?.scrollTop === pendingRestore.view?.scrollTop ) { return; } @@ -1278,8 +1285,11 @@ export class Editor implements DiffsEditor { const view = this.getState().view; const applyState = (state: EditorState | undefined): void => { const currentView = this.getState().view; + // scrollTop is deliberately not part of this staleness check: the + // surface can legitimately adjust vertical scroll while an async read + // is in flight (height reconciliation, clamping), and that must not + // block the restore. if ( - state === undefined || generation !== this.#stateRestoreGeneration || this.#textDocument !== textDocument || textDocument.version !== documentVersion || @@ -1290,6 +1300,15 @@ export class Editor implements DiffsEditor { ) { return; } + if (state === undefined) { + // No stored record for this cacheKey — the file is opened for the + // first time. Start the code scroller and the viewport at the top + // instead of inheriting whatever offset the previous file left in a + // viewport that stays mounted across switches. + this.#fileInstance?.setCodeScrollLeft(0); + this.#setViewportScrollTop(0); + return; + } this.setState(cloneEditorState(state)); }; const readState = (): void | Promise => { @@ -1468,6 +1487,37 @@ export class Editor implements DiffsEditor { queueRender(callback); } + // Get the editor's scrolling viewport, return undefined if the Virtualizer + // is not present.` + #getScrollViewport(): HTMLElement | Document | undefined { + const viewport = this.#fileInstance?.getEditorViewport?.(); + if (viewport !== undefined) { + return viewport; + } + const fileContainer = this.#fileContainer; + if (this.#fileInstance == null || fileContainer == null) { + return undefined; + } + return lookupScrollContainer(fileContainer); + } + + #getViewportScrollTop(): number { + const viewport = this.#getScrollViewport(); + if (viewport instanceof HTMLElement) { + return viewport.scrollTop; + } + return viewport?.defaultView?.scrollY ?? 0; + } + + #setViewportScrollTop(scrollTop: number): void { + const viewport = this.#getScrollViewport(); + if (viewport instanceof HTMLElement) { + viewport.scrollTop = scrollTop; + } else if (viewport instanceof Document) { + viewport.defaultView?.scrollTo({ top: scrollTop }); + } + } + #initialize(): void { // Safari doesn't support `::selection` for slot elements in ShadowDOM, // Add a global style to disable selection for slot elements @@ -3468,9 +3518,11 @@ export class Editor implements DiffsEditor { ) { return undefined; } - const viewport = - this.#fileInstance?.getEditorViewport?.() ?? - this.#getDefaultEditorViewport(fileContainer); + + const viewport = this.#getScrollViewport(); + if (viewport === undefined) { + return undefined; + } let viewportTop: number; let viewportBottom: number; @@ -3479,12 +3531,8 @@ export class Editor implements DiffsEditor { viewportTop = viewportRect.top; viewportBottom = viewportRect.bottom; } else { - const viewportWindow = viewport.defaultView; - if (viewportWindow == null) { - return undefined; - } viewportTop = 0; - viewportBottom = viewportWindow.innerHeight; + viewportBottom = viewport.defaultView?.innerHeight ?? 0; } const stickyHeader = fileContainer.shadowRoot?.querySelector( @@ -3521,32 +3569,6 @@ export class Editor implements DiffsEditor { return undefined; } - // Non-virtualized surfaces inherit visibility from their nearest vertical - // scrollport; page-scrolling surfaces use the owning document. - #getDefaultEditorViewport( - fileContainer: HTMLElement - ): HTMLElement | Document { - const ownerDocument = fileContainer.ownerDocument; - let element = fileContainer.parentElement; - while ( - element != null && - element !== ownerDocument.body && - element !== ownerDocument.documentElement - ) { - const overflowY = - element.ownerDocument.defaultView?.getComputedStyle(element).overflowY; - if ( - overflowY === 'auto' || - overflowY === 'scroll' || - overflowY === 'overlay' - ) { - return element; - } - element = element.parentElement; - } - return ownerDocument; - } - #focusAtPosition(position: Position, preventScroll: boolean): void { const selection: EditorSelection = { start: position, diff --git a/packages/diffs/src/editor/utils.ts b/packages/diffs/src/editor/utils.ts index 26ca9e920..9c188a446 100644 --- a/packages/diffs/src/editor/utils.ts +++ b/packages/diffs/src/editor/utils.ts @@ -144,3 +144,30 @@ export function getGraphemeSegmenter(): Intl.Segmenter | undefined { } return graphemeSegmenter; } + +// lookup the scroll container for the given element +export function lookupScrollContainer( + element: HTMLElement +): HTMLElement | Document { + const ownerDocument = element.ownerDocument; + let parentElement = element.parentElement; + while ( + parentElement != null && + parentElement !== ownerDocument.body && + parentElement !== ownerDocument.documentElement + ) { + const overflowY = + parentElement.ownerDocument.defaultView?.getComputedStyle( + parentElement + ).overflowY; + if ( + overflowY === 'auto' || + overflowY === 'scroll' || + overflowY === 'overlay' + ) { + return parentElement; + } + parentElement = parentElement.parentElement; + } + return ownerDocument; +} diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 96f3a87d6..67c013c7f 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1256,6 +1256,8 @@ export interface EditorSelection extends Range { export interface EditorViewState { /** Horizontal position owned by the current editable code scroller. */ scrollLeft: number; + /** Vertical position of the editor viewport. */ + scrollTop?: number; } export interface EditorState { From ce2d13377055e07491c211536a7337f3c3fa59d6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 4 Aug 2026 00:29:40 +0800 Subject: [PATCH 03/12] Update testings --- .../test/editorPersistStateLifecycle.test.ts | 248 ++++++++++++++++++ packages/diffs/test/editorState.test.ts | 4 +- 2 files changed, 250 insertions(+), 2 deletions(-) diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index 13931598c..dd99a3a21 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type IStateStorage } from '../src/edit'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; @@ -448,4 +449,251 @@ describe('Editor persisted state lifecycle', () => { dom.cleanup(); } }); + + // Renders a file inside a scrollable wrapper (the editor's default viewport + // is its nearest overflow-y:auto ancestor) so the restore path's scroll + // behavior is observable. The wrapper starts scrolled away from 0,0 as if a + // previously open file had left an offset behind. + function attachFileInScrolledViewport( + editor: Editor, + fileContents: FileContents + ): AttachedFile & { viewport: HTMLElement } { + const viewport = document.createElement('div'); + viewport.style.overflowY = 'auto'; + document.body.appendChild(viewport); + const container = document.createElement('div'); + viewport.appendChild(container); + viewport.scrollTop = 40; + viewport.scrollLeft = 8; + + const file = new File({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + file.render({ + file: fileContents, + fileContainer: container, + forceRender: true, + }); + editor.edit(file); + return { container, file, viewport }; + } + + test('a missing state record resets the viewport scroll to 0,0', async () => { + const dom = installDom(); + const storage: IStateStorage = { + get: () => undefined, + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; + + try { + attached = attachFileInScrolledViewport(editor, { + ...ORIGINAL_FILE, + }); + const { viewport } = attached; + // The reset targets the viewport's vertical position (and the code + // scroller's own horizontal offset); the viewport's scrollLeft is left + // to the host. + await waitFor(() => viewport.scrollTop === 0); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + + test('a record with scrollTop restores the viewport position', async () => { + const dom = installDom(); + const storage: IStateStorage = { + get: () => ({ ...savedCaret(3), view: { scrollLeft: 0, scrollTop: 25 } }), + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; + + try { + attached = attachFileInScrolledViewport(editor, { + ...ORIGINAL_FILE, + }); + const { viewport } = attached; + await waitFor(() => viewport.scrollTop === 25); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + + // The shared-editor pattern (e.g. an EditProvider handing every keyed + // surface the same Editor): the outgoing surface's cleanUp persists its + // record into the editor's per-instance storage, and re-attaching a new + // surface for the same cacheKey restores it — including the viewport's + // vertical position. + test('a reused editor restores viewport scrollTop across surface remounts', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; + let reattached: (AttachedFile & { viewport: HTMLElement }) | undefined; + + try { + attached = attachFileInScrolledViewport(editor, { + ...ORIGINAL_FILE, + }); + // First visit has no record: the viewport resets to 0. Then the user + // scrolls, and leaving the file persists that position. + await waitFor(() => attached!.viewport.scrollTop === 0); + attached.viewport.scrollTop = 33; + editor.cleanUp(); + attached.file.cleanUp(); + + reattached = attachFileInScrolledViewport(editor, { + ...ORIGINAL_FILE, + }); + await waitFor(() => reattached!.viewport.scrollTop === 33); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + reattached?.file.cleanUp(); + dom.cleanup(); + } + }); + + // Diffs persist only their serializable state, keyed by the cacheKey + // parseDiffFromFile derives from the file pair. A first attach has no + // record (reset to 0,0); a later fresh editor + fresh FileDiff for the same + // pair restores the persisted viewport position. + test('with persistState, a diff resets on first attach and restores on revisit', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + const viewport = document.createElement('div'); + viewport.style.overflowY = 'auto'; + document.body.appendChild(viewport); + const container = document.createElement('div'); + viewport.appendChild(container); + viewport.scrollTop = 40; + viewport.scrollLeft = 8; + + const oldFile: FileContents = { name: 'diffed.ts', contents: 'alpha\n' }; + const newFile: FileContents = { + name: 'diffed.ts', + contents: 'alpha\nbravo\n', + }; + const fileDiff = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const revisitDiff = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + try { + fileDiff.render({ + oldFile, + newFile, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + await waitFor(() => viewport.scrollTop === 0); + + // Scroll the diff, tear the surface down, and revisit the same pair on + // a fresh component with the same editor — its record must restore the + // position. + viewport.scrollTop = 27; + editor.cleanUp(); + fileDiff.cleanUp(); + container.innerHTML = ''; + viewport.scrollTop = 5; + + revisitDiff.render({ + oldFile, + newFile, + fileContainer: container, + forceRender: true, + }); + editor.edit(revisitDiff); + await waitFor(() => viewport.scrollTop === 27); + } finally { + editor.cleanUp(); + fileDiff.cleanUp(); + revisitDiff.cleanUp(); + dom.cleanup(); + } + }); + + test('without persistState, attaching a diff leaves the viewport alone', async () => { + const dom = installDom(); + const editor = new Editor(); + const viewport = document.createElement('div'); + viewport.style.overflowY = 'auto'; + document.body.appendChild(viewport); + const container = document.createElement('div'); + viewport.appendChild(container); + viewport.scrollTop = 40; + + const fileDiff = new FileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + try { + fileDiff.render({ + oldFile: { name: 'diffed.ts', contents: 'alpha\n' }, + newFile: { name: 'diffed.ts', contents: 'alpha\nbravo\n' }, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + // Attach work settles asynchronously; give it time to (incorrectly) + // move the viewport before asserting it stayed put. + await wait(50); + expect(viewport.scrollTop).toBe(40); + } finally { + editor.cleanUp(); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + + test('a record without scrollTop leaves the viewport scroll alone', async () => { + const dom = installDom(); + const storage: IStateStorage = { + // A record persisted before scrollTop existed: setState still honors + // scrollLeft exactly but must not move the viewport vertically. + get: () => ({ ...savedCaret(3), view: { scrollLeft: 0 } }), + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; + + try { + attached = attachFileInScrolledViewport(editor, { + ...ORIGINAL_FILE, + }); + const { viewport } = attached; + await waitFor( + () => editor.getState().selections?.[0]?.start.character === 3 + ); + expect(viewport.scrollTop).toBe(40); + expect(viewport.scrollLeft).toBe(8); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); }); diff --git a/packages/diffs/test/editorState.test.ts b/packages/diffs/test/editorState.test.ts index 208d73215..fdb1a3977 100644 --- a/packages/diffs/test/editorState.test.ts +++ b/packages/diffs/test/editorState.test.ts @@ -159,7 +159,7 @@ describe('Editor state', () => { editor.edit(component); component.codeScrollLeft = 24; - expect(editor.getState().view).toEqual({ scrollLeft: 24 }); + expect(editor.getState().view).toEqual({ scrollLeft: 24, scrollTop: 0 }); } finally { editor.cleanUp(); component.cleanUp(); @@ -213,7 +213,7 @@ describe('Editor state', () => { component.codeScrollLeft = 24; editor.cleanUp(); - expect(storedState?.view).toEqual({ scrollLeft: 24 }); + expect(storedState?.view).toEqual({ scrollLeft: 24, scrollTop: 0 }); } finally { editor.cleanUp(); component.cleanUp(); From d487e1249cdb9557df4b7ab5314c7e522b57fee1 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 4 Aug 2026 00:30:09 +0800 Subject: [PATCH 04/12] Update home page edit demo --- apps/docs/app/(diffs)/_home/AgentUi.tsx | 351 ++++++++++------------- apps/docs/app/(diffs)/_home/agent-ui.css | 9 +- 2 files changed, 156 insertions(+), 204 deletions(-) diff --git a/apps/docs/app/(diffs)/_home/AgentUi.tsx b/apps/docs/app/(diffs)/_home/AgentUi.tsx index a0e2c0d59..0ce8a6b23 100644 --- a/apps/docs/app/(diffs)/_home/AgentUi.tsx +++ b/apps/docs/app/(diffs)/_home/AgentUi.tsx @@ -1,8 +1,8 @@ 'use client'; import { DEFAULT_THEMES, type FileDiffMetadata } from '@pierre/diffs'; -import type { EditorOptions } from '@pierre/diffs/edit'; -import { File, FileDiff } from '@pierre/diffs/react'; +import { Editor } from '@pierre/diffs/edit'; +import { EditProvider, File, FileDiff, Virtualizer } from '@pierre/diffs/react'; import { IconArrow, IconChevronSm, @@ -21,7 +21,6 @@ import { type CSSProperties, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -41,12 +40,6 @@ import { getSessionGitStatus, getSessionPaths, } from './mockData'; -// Runs as a layout effect in the browser (so DOM reads/writes land before the -// next paint) but falls back to useEffect during SSR, where useLayoutEffect -// would warn. The demo is server-rendered, so the fallback matters. -const useIsomorphicLayoutEffect = - typeof window === 'undefined' ? useEffect : useLayoutEffect; - // Added/removed line totals for a single file's diff. interface DiffStats { additions: number; @@ -66,33 +59,6 @@ function countDiffStats(diff: FileDiffMetadata): DiffStats { return { additions, deletions }; } -// The editor's stylesheet flattens every line number to one neutral colour -// (`--diffs-editor-line-number-fg`) and is injected as an unlayered