diff --git a/packages/domscribe-next/package.json b/packages/domscribe-next/package.json index cce890a..0576c25 100644 --- a/packages/domscribe-next/package.json +++ b/packages/domscribe-next/package.json @@ -8,7 +8,7 @@ "access": "restricted" }, "distExports": { - "./runtime": "./runtime/index.js", + "./auto-init": "./auto-init/index.js", "./noop/overlay": "./noop/overlay.js" }, "dependencies": { diff --git a/packages/domscribe-next/src/runtime/domscribe-provider.spec.ts b/packages/domscribe-next/src/auto-init/auto-init.spec.ts similarity index 51% rename from packages/domscribe-next/src/runtime/domscribe-provider.spec.ts rename to packages/domscribe-next/src/auto-init/auto-init.spec.ts index e30750a..950687d 100644 --- a/packages/domscribe-next/src/runtime/domscribe-provider.spec.ts +++ b/packages/domscribe-next/src/auto-init/auto-init.spec.ts @@ -1,14 +1,6 @@ +// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// Track the latest effect callback registered via useEffect -let capturedEffect: (() => void) | undefined; - -vi.mock('react', () => ({ - useEffect: (fn: () => void) => { - capturedEffect = fn; - }, -})); - const mockInitialize = vi.fn(); const mockGetInstance = vi.fn(() => ({ initialize: mockInitialize })); const mockCreateReactAdapter = vi.fn(() => ({ name: 'react-adapter' })); @@ -26,51 +18,20 @@ vi.mock('@domscribe/overlay', () => ({ initOverlay: mockInitOverlay, })); -import { DomscribeDevProvider } from './domscribe-provider.js'; - -describe('DomscribeDevProvider', () => { +describe('auto-init', () => { beforeEach(() => { - capturedEffect = undefined; vi.clearAllMocks(); + vi.resetModules(); }); afterEach(() => { - vi.unstubAllEnvs(); delete (globalThis as Record)[ '__DOMSCRIBE_OVERLAY_OPTIONS__' ]; }); - it('should return null (renders nothing)', () => { - const result = DomscribeDevProvider(); - - expect(result).toBeNull(); - }); - - it('should register a useEffect with empty dependency array', () => { - DomscribeDevProvider(); - - expect(capturedEffect).toBeDefined(); - }); - - it('should skip initialization in production', () => { - vi.stubEnv('NODE_ENV', 'production'); - - DomscribeDevProvider(); - capturedEffect?.(); - - // Should not attempt to import runtime or react adapter - expect(mockGetInstance).not.toHaveBeenCalled(); - expect(mockCreateReactAdapter).not.toHaveBeenCalled(); - }); - - it('should initialize runtime and react adapter in development', async () => { - vi.stubEnv('NODE_ENV', 'development'); - - DomscribeDevProvider(); - capturedEffect?.(); - - // Allow dynamic import promises to resolve + it('should initialize runtime and react adapter', async () => { + await import('./index.js'); await vi.dynamicImportSettled(); expect(mockGetInstance).toHaveBeenCalled(); @@ -81,25 +42,18 @@ describe('DomscribeDevProvider', () => { }); it('should initialize overlay when __DOMSCRIBE_OVERLAY_OPTIONS__ is set', async () => { - vi.stubEnv('NODE_ENV', 'development'); (globalThis as Record)['__DOMSCRIBE_OVERLAY_OPTIONS__'] = { initialMode: 'collapsed', }; - DomscribeDevProvider(); - capturedEffect?.(); - + await import('./index.js'); await vi.dynamicImportSettled(); expect(mockInitOverlay).toHaveBeenCalled(); }); it('should not initialize overlay when __DOMSCRIBE_OVERLAY_OPTIONS__ is not set', async () => { - vi.stubEnv('NODE_ENV', 'development'); - - DomscribeDevProvider(); - capturedEffect?.(); - + await import('./index.js'); await vi.dynamicImportSettled(); expect(mockInitOverlay).not.toHaveBeenCalled(); diff --git a/packages/domscribe-next/src/auto-init/index.ts b/packages/domscribe-next/src/auto-init/index.ts new file mode 100644 index 0000000..87b7c0b --- /dev/null +++ b/packages/domscribe-next/src/auto-init/index.ts @@ -0,0 +1,36 @@ +/** + * Auto-initialize Domscribe runtime + overlay from the loader preamble. + * + * This module replaces DomscribeDevProvider — it runs at module load time + * instead of requiring a React component in the tree. The turbopack loader + * injects `import('@domscribe/next/auto-init').catch(function(){})` into + * every transformed file, guarded by a `__DOMSCRIBE_AUTO_INIT__` flag to + * ensure it only executes once. + * + * @module @domscribe/next/auto-init + */ + +if (typeof window !== 'undefined') { + // Initialize runtime + React adapter + Promise.all([import('@domscribe/runtime'), import('@domscribe/react')]) + .then(([{ RuntimeManager }, { createReactAdapter }]) => { + RuntimeManager.getInstance().initialize({ + adapter: createReactAdapter(), + }); + }) + .catch(() => { + // Never break the app + }); + + // Initialize overlay if configured (globals set by loader preamble) + const win = globalThis as Record; + if (win['__DOMSCRIBE_OVERLAY_OPTIONS__']) { + import('@domscribe/overlay') + .then(({ initOverlay }) => { + initOverlay(); + }) + .catch(() => { + // Overlay is optional + }); + } +} diff --git a/packages/domscribe-next/src/index.ts b/packages/domscribe-next/src/index.ts index e99b358..989af49 100644 --- a/packages/domscribe-next/src/index.ts +++ b/packages/domscribe-next/src/index.ts @@ -3,7 +3,7 @@ * * Zero-config Next.js integration that provides: * - Build-time AST injection of stable element IDs (Turbopack + Webpack) - * - DomscribeDevProvider component for runtime initialization + * - Auto-initialization of runtime + overlay via loader preamble * - Relay auto-start and overlay injection via loader-injected window globals * * @module @domscribe/next diff --git a/packages/domscribe-next/src/runtime/domscribe-provider.ts b/packages/domscribe-next/src/runtime/domscribe-provider.ts deleted file mode 100644 index 4b2301c..0000000 --- a/packages/domscribe-next/src/runtime/domscribe-provider.ts +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -/** - * DomscribeDevProvider - React client component for runtime initialization - * @module @domscribe/next/runtime/domscribe-provider - */ -import { useEffect } from 'react'; - -/** - * Client component that initializes Domscribe runtime + overlay in dev mode. - * - * Add to your root layout: - * ```tsx - * import { DomscribeDevProvider } from '@domscribe/next/runtime'; - * - * export default function RootLayout({ children }) { - * return ( - * - * - * - * {children} - * - * - * ); - * } - * ``` - * - * Renders nothing — only performs side-effect initialization. - * All imports are dynamic and wrapped in catch() — never breaks the app. - */ -export function DomscribeDevProvider() { - useEffect(() => { - // Dev-only: skip all initialization in production. - // Next.js inlines process.env.NODE_ENV at build time, so the - // bundler can dead-code-eliminate the dynamic imports below. - if (process.env.NODE_ENV === 'production') return; - - // Relay and overlay globals (window.__DOMSCRIBE_RELAY_PORT__, etc.) - // are injected by the turbopack/webpack loader into a transformed - // module. Module-level code executes before React mounts, so the - // globals are already available by the time this effect fires. - - // Initialize runtime + react adapter - Promise.all([import('@domscribe/runtime'), import('@domscribe/react')]) - .then(([{ RuntimeManager }, { createReactAdapter }]) => { - RuntimeManager.getInstance().initialize({ - adapter: createReactAdapter(), - }); - }) - .catch((err) => { - // Never break the app - console.warn('[domscribe] Runtime init failed:', err); - }); - - // Initialize overlay if configured (globals set by loader) - const win = globalThis as Record; - if (win['__DOMSCRIBE_OVERLAY_OPTIONS__']) { - import('@domscribe/overlay') - .then(({ initOverlay }) => { - initOverlay(); - }) - .catch((err) => { - // Overlay is optional - console.warn('[domscribe] Overlay init failed:', err); - }); - } - }, []); - - return null; -} diff --git a/packages/domscribe-next/src/runtime/index.ts b/packages/domscribe-next/src/runtime/index.ts deleted file mode 100644 index fb5eb6f..0000000 --- a/packages/domscribe-next/src/runtime/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Runtime exports for @domscribe/next - * @module @domscribe/next/runtime - */ -export { DomscribeDevProvider } from './domscribe-provider.js'; diff --git a/packages/domscribe-next/src/with-domscribe.spec.ts b/packages/domscribe-next/src/with-domscribe.spec.ts index 79e7f7a..cee68ec 100644 --- a/packages/domscribe-next/src/with-domscribe.spec.ts +++ b/packages/domscribe-next/src/with-domscribe.spec.ts @@ -378,7 +378,7 @@ describe('withDomscribe', () => { expect(options['debug']).toBe(false); }); - it('should default overlay to false', () => { + it('should default overlay to true', () => { const result = withDomscribe()({}); const turbopack = result.turbopack as Record; @@ -386,7 +386,7 @@ describe('withDomscribe', () => { const jsxRule = rules['*.jsx'] as Record; const loaders = jsxRule['loaders'] as Array>; const options = loaders[0]['options'] as Record; - expect(options['overlay']).toBe(false); + expect(options['overlay']).toBe(true); }); it('should default relay to empty object', () => { diff --git a/packages/domscribe-next/src/with-domscribe.ts b/packages/domscribe-next/src/with-domscribe.ts index 4e816a3..b8edb1b 100644 --- a/packages/domscribe-next/src/with-domscribe.ts +++ b/packages/domscribe-next/src/with-domscribe.ts @@ -45,8 +45,7 @@ const DEFAULT_EXCLUDE = /node_modules|\.test\.|\.spec\./i; * Next.js 16+ Turbopack). * * In production, injects resolve aliases that replace @domscribe/overlay - * with a no-op stub so DomscribeDevProvider is safe to leave in the - * component tree without bloating the bundle. + * with a no-op stub as a safety net against accidental imports. * * @example * ```js @@ -96,11 +95,9 @@ function resolveNoopOverlay(): string { /** * Replace @domscribe/overlay with a no-op stub in production builds. * - * DomscribeDevProvider lives in the component tree unconditionally (so - * users don't need conditional imports in their layout). In production - * the provider is inert, but bundlers still trace its dynamic import() - * and pull in the full overlay package. Aliasing it to a tiny stub - * keeps the production bundle clean. + * Safety net: if any code path accidentally imports @domscribe/overlay + * in production, this alias ensures the bundle gets a tiny stub instead + * of the full overlay package. */ function applyProductionAliases(nextConfig: NextConfig): NextConfig { const noopOverlay = resolveNoopOverlay(); @@ -144,7 +141,7 @@ function applyDevTransforms( exclude = DEFAULT_EXCLUDE, debug = false, relay = {}, - overlay = false, + overlay = true, } = options; return { diff --git a/packages/domscribe-next/vite.config.ts b/packages/domscribe-next/vite.config.ts index deecef2..574faab 100644 --- a/packages/domscribe-next/vite.config.ts +++ b/packages/domscribe-next/vite.config.ts @@ -1,6 +1,17 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + resolve: { + alias: { + // auto-init dynamically imports @domscribe/overlay which isn't a direct + // dependency — help Vite resolve it for tests (vi.mock intercepts at runtime) + '@domscribe/overlay': path.resolve( + __dirname, + '../domscribe-overlay/src/index.ts', + ), + }, + }, test: { name: '@domscribe/next', watch: false, diff --git a/packages/domscribe-test-fixtures/fixtures/_templates/next/app/layout.tsx__tmpl__ b/packages/domscribe-test-fixtures/fixtures/_templates/next/app/layout.tsx__tmpl__ index 9dd98c6..d686156 100644 --- a/packages/domscribe-test-fixtures/fixtures/_templates/next/app/layout.tsx__tmpl__ +++ b/packages/domscribe-test-fixtures/fixtures/_templates/next/app/layout.tsx__tmpl__ @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { DomscribeDevProvider } from '@domscribe/next/runtime'; import './globals.css'; export const metadata: Metadata = { @@ -15,7 +14,6 @@ export default function RootLayout({ return ( - {children} diff --git a/packages/domscribe-test-fixtures/fixtures/next/v15/js/app/layout.tsx b/packages/domscribe-test-fixtures/fixtures/next/v15/js/app/layout.tsx index 4b435d7..dd648c8 100644 --- a/packages/domscribe-test-fixtures/fixtures/next/v15/js/app/layout.tsx +++ b/packages/domscribe-test-fixtures/fixtures/next/v15/js/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { DomscribeDevProvider } from '@domscribe/next/runtime'; import './globals.css'; export const metadata: Metadata = { @@ -14,10 +13,7 @@ export default function RootLayout({ }) { return ( - - - {children} - + {children} ); } diff --git a/packages/domscribe-test-fixtures/fixtures/next/v15/ts/app/layout.tsx b/packages/domscribe-test-fixtures/fixtures/next/v15/ts/app/layout.tsx index 4b435d7..dd648c8 100644 --- a/packages/domscribe-test-fixtures/fixtures/next/v15/ts/app/layout.tsx +++ b/packages/domscribe-test-fixtures/fixtures/next/v15/ts/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { DomscribeDevProvider } from '@domscribe/next/runtime'; import './globals.css'; export const metadata: Metadata = { @@ -14,10 +13,7 @@ export default function RootLayout({ }) { return ( - - - {children} - + {children} ); } diff --git a/packages/domscribe-test-fixtures/fixtures/next/v16/js/app/layout.tsx b/packages/domscribe-test-fixtures/fixtures/next/v16/js/app/layout.tsx index 93f47de..bb8d22c 100644 --- a/packages/domscribe-test-fixtures/fixtures/next/v16/js/app/layout.tsx +++ b/packages/domscribe-test-fixtures/fixtures/next/v16/js/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { DomscribeDevProvider } from '@domscribe/next/runtime'; import './globals.css'; export const metadata: Metadata = { @@ -14,10 +13,7 @@ export default function RootLayout({ }) { return ( - - - {children} - + {children} ); } diff --git a/packages/domscribe-test-fixtures/fixtures/next/v16/ts/app/layout.tsx b/packages/domscribe-test-fixtures/fixtures/next/v16/ts/app/layout.tsx index 93f47de..bb8d22c 100644 --- a/packages/domscribe-test-fixtures/fixtures/next/v16/ts/app/layout.tsx +++ b/packages/domscribe-test-fixtures/fixtures/next/v16/ts/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { DomscribeDevProvider } from '@domscribe/next/runtime'; import './globals.css'; export const metadata: Metadata = { @@ -14,10 +13,7 @@ export default function RootLayout({ }) { return ( - - - {children} - + {children} ); } diff --git a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts index 6c6aad8..e5028c0 100644 --- a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts +++ b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts @@ -346,7 +346,7 @@ describe('Turbopack Loader', () => { expect(mockInjector.inject).toHaveBeenCalled(); expect(asyncCallback).toHaveBeenCalledWith( null, - 'transformed', + expect.stringContaining('transformed'), expect.objectContaining({ version: 3 }), ); }); @@ -520,6 +520,28 @@ describe('Turbopack Loader', () => { expect(output2).toContain('__DOMSCRIBE_RELAY_PORT__'); }); + it('should inject auto-init import with dedup guard', async () => { + // Arrange + const source = 'export function App() { return
Hello
; }'; + const context = createLoaderContext('/test/App.tsx'); + const asyncCallback = vi.fn(); + context.async = vi.fn(() => asyncCallback); + mockInjector.inject.mockReturnValue( + createMockInjectorResult('transformed', 1), + ); + + // Act + loaderModule.default.call(context, source); + await vi.waitFor(() => expect(asyncCallback).toHaveBeenCalled()); + + // Assert + const outputCode = asyncCallback.mock.calls[0][1] as string; + expect(outputCode).toContain('__DOMSCRIBE_AUTO_INIT__'); + expect(outputCode).toContain( + "import('@domscribe/next/auto-init').catch(function(){})", + ); + }); + it('should inject overlay options when configured', async () => { // Arrange const source = 'export function App() { return
Hello
; }'; diff --git a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts index 326a6e8..fba259b 100644 --- a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts +++ b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts @@ -139,12 +139,28 @@ async function doInit( * Injected once into the first transformed file so the client-side * provider (DomscribeDevProvider) can discover relay/overlay config * without relying on NEXT_PUBLIC_* env vars. + * + * Also: + * - Installs a one-time console.error filter that suppresses React's + * "Invalid prop `data-ds` supplied to `React.Fragment`" warning + * - Triggers auto-initialization of runtime + overlay via + * `import('@domscribe/next/auto-init')`, guarded by a + * `__DOMSCRIBE_AUTO_INIT__` flag to run only once per page load */ -function buildClientGlobalsPreamble( - options: TurbopackLoaderOptions, -): string | null { +function buildClientGlobalsPreamble(options: TurbopackLoaderOptions): string { const parts: string[] = []; + // Suppress React Fragment prop warning for data-ds (once per page load). + parts.push( + `if(!window.__DOMSCRIBE_CONSOLE_PATCHED__){` + + `window.__DOMSCRIBE_CONSOLE_PATCHED__=true;` + + `var _ce=console.error;` + + `console.error=function(){` + + `if(typeof arguments[0]==='string'&&arguments[0].indexOf('data-ds')!==-1&&arguments[0].indexOf('React.Fragment')!==-1)return;` + + `return _ce.apply(console,arguments)` + + `}}`, + ); + if (initResult.relayPort !== undefined) { parts.push(`window.__DOMSCRIBE_RELAY_PORT__=${initResult.relayPort}`); } @@ -162,11 +178,13 @@ function buildClientGlobalsPreamble( ); } - if (parts.length === 0) { - return null; - } - - return `if(typeof window!=='undefined'){${parts.join(';')}}\n`; + return ( + `if(typeof window!=='undefined'){${parts.join(';')};` + + `if(!window.__DOMSCRIBE_AUTO_INIT__){` + + `window.__DOMSCRIBE_AUTO_INIT__=true;` + + `import('@domscribe/next/auto-init').catch(function(){})` + + `}}\n` + ); } /** diff --git a/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts b/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts index acec77c..1baf15f 100644 --- a/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts +++ b/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts @@ -304,6 +304,23 @@ export function domscribe(options: VitePluginOptions = {}): Plugin { transformIndexHtml(): IndexHtmlTransformResult | undefined { const tags: HtmlTagDescriptor[] = []; + // Suppress React's "Invalid prop `data-ds` supplied to React.Fragment" + // warning. Fires when a component resolves to Fragment at runtime + // (e.g. `const P = hasKey ? dynamic(...) : Fragment`). Harmless but + // clutters the console and triggers error overlays. + tags.push({ + tag: 'script', + children: + `if(!window.__DOMSCRIBE_CONSOLE_PATCHED__){` + + `window.__DOMSCRIBE_CONSOLE_PATCHED__=true;` + + `var _ce=console.error;` + + `console.error=function(){` + + `if(typeof arguments[0]==='string'&&arguments[0].indexOf('data-ds')!==-1&&arguments[0].indexOf('React.Fragment')!==-1)return;` + + `return _ce.apply(console,arguments)` + + `}}`, + injectTo: 'head-prepend', + }); + // Inject relay port for overlay discovery // The overlay (browser UI) needs to know which port to connect to if (relayPort && relayHost) { @@ -338,10 +355,7 @@ export function domscribe(options: VitePluginOptions = {}): Plugin { }); } - if (tags.length > 0) { - return { html: '', tags }; - } - return undefined; + return { html: '', tags }; }, }; } diff --git a/packages/domscribe-transform/src/plugins/webpack/webpack.plugin.ts b/packages/domscribe-transform/src/plugins/webpack/webpack.plugin.ts index fa8f300..3f17fbb 100644 --- a/packages/domscribe-transform/src/plugins/webpack/webpack.plugin.ts +++ b/packages/domscribe-transform/src/plugins/webpack/webpack.plugin.ts @@ -268,6 +268,21 @@ export class DomscribeWebpackPlugin { const headTags: string[] = []; const bodyTags: string[] = []; + // Suppress React's "Invalid prop `data-ds` supplied to React.Fragment" + // warning. Fires when a component resolves to Fragment at runtime + // (e.g. `const P = hasKey ? dynamic(...) : Fragment`). + headTags.push( + ``, + ); + // Add relay script tag to head const relayTag = this.getRelayScriptTag(); if (relayTag) {