From d9df3b0aeeb21379880e430cc3981720da730d4b Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Mon, 13 Jul 2026 22:42:26 +0300 Subject: [PATCH] feat: add withDocumentTitle reusable Atom extension that mirrors a string atom's value into document.title while connected. Sets the title on the first subscriber, tracks changes via addChangeHook, and stops on disconnect. No-ops under SSR (document undefined). Works on any AtomLike, primarily computed titles. Co-Authored-By: Claude Opus 4.8 --- .../with-document-title.browser.test.ts | 69 +++++++++++++++++++ .../with-document-title.example.ts | 25 +++++++ .../document-title/with-document-title.md | 57 +++++++++++++++ .../with-document-title.meta.ts | 13 ++++ .../with-document-title.node.test.ts | 24 +++++++ .../document-title/with-document-title.ts | 34 +++++++++ 6 files changed, 222 insertions(+) create mode 100644 src/reusables/document-title/with-document-title.browser.test.ts create mode 100644 src/reusables/document-title/with-document-title.example.ts create mode 100644 src/reusables/document-title/with-document-title.md create mode 100644 src/reusables/document-title/with-document-title.meta.ts create mode 100644 src/reusables/document-title/with-document-title.node.test.ts create mode 100644 src/reusables/document-title/with-document-title.ts diff --git a/src/reusables/document-title/with-document-title.browser.test.ts b/src/reusables/document-title/with-document-title.browser.test.ts new file mode 100644 index 0000000..ded1db5 --- /dev/null +++ b/src/reusables/document-title/with-document-title.browser.test.ts @@ -0,0 +1,69 @@ +import { assert, atom, computed } from '@reatom/core' +import { afterEach, describe, expect, notify, test } from 'test' + +import { withDocumentTitle } from './with-document-title' + +describe('withDocumentTitle', () => { + assert(typeof document !== 'undefined', 'Test requires a browser environment') + + const originalTitle = document.title + afterEach(() => { + document.title = originalTitle + }) + + test('sets document.title immediately on subscribe', () => { + document.title = 'initial' + + const title = computed(() => 'Home', 'title').extend(withDocumentTitle()) + const unsub = title.subscribe(() => {}) + notify() + + expect(document.title).toBe('Home') + unsub() + }) + + test('does not touch document.title without a subscriber', () => { + document.title = 'untouched' + + atom('Home', 'title').extend(withDocumentTitle()) + notify() + + expect(document.title).toBe('untouched') + }) + + test('updates document.title when the atom changes', () => { + const label = atom('Home', 'label') + const title = computed(() => `${label()} | App`, 'title').extend( + withDocumentTitle(), + ) + const unsub = title.subscribe(() => {}) + notify() + expect(document.title).toBe('Home | App') + + label.set('Settings') + notify() + expect(document.title).toBe('Settings | App') + + unsub() + }) + + test('stops updating document.title after unsubscribe', () => { + const label = atom('Home', 'label') + const title = computed(() => `${label()} | App`, 'title').extend( + withDocumentTitle(), + ) + const unsub = title.subscribe(() => {}) + notify() + + label.set('Settings') + notify() + expect(document.title).toBe('Settings | App') + + unsub() + notify() + + label.set('Profile') + notify() + expect(document.title).toBe('Settings | App') + }) +}) diff --git a/src/reusables/document-title/with-document-title.example.ts b/src/reusables/document-title/with-document-title.example.ts new file mode 100644 index 0000000..a0d9807 --- /dev/null +++ b/src/reusables/document-title/with-document-title.example.ts @@ -0,0 +1,25 @@ +import { atom, computed } from '@reatom/core' + +import { withDocumentTitle } from './with-document-title' + +// --- Derive a document title from a page label --- + +const pageLabel = atom('Home', 'pageLabel') + +const documentTitle = computed( + () => `${pageLabel()} | My App`, + 'documentTitle', +).extend(withDocumentTitle()) + +// The sync is active only while the atom has subscribers. +// Before subscribing, document.title is untouched. +const unsub = documentTitle.subscribe(() => {}) +console.log(document.title) // 'Home | My App' + +pageLabel.set('Settings') +console.log(document.title) // 'Settings | My App' + +// After unsubscribing, further changes no longer touch document.title. +unsub() +pageLabel.set('Profile') +console.log(document.title) // still 'Settings | My App' diff --git a/src/reusables/document-title/with-document-title.md b/src/reusables/document-title/with-document-title.md new file mode 100644 index 0000000..601eaee --- /dev/null +++ b/src/reusables/document-title/with-document-title.md @@ -0,0 +1,57 @@ +# withDocumentTitle + +Atom extension that mirrors a string atom's value into `document.title`. + +## `withDocumentTitle()` + +Applies to any `AtomLike` (typically a `computed` that derives the +title). While the atom is connected, its current value is written to +`document.title`. + +### Parameters + +None. + +### Returns + +Extension that adds no new properties — it only wires the connect-gated side +effect onto the target atom. + +### Behavior + +- **Connect-gated.** The sync activates on the first subscriber: it sets + `document.title` immediately, then keeps it in sync on every change. With no + subscriber, `document.title` is never touched. +- **Stops on disconnect.** When the last subscriber leaves, the change hook is + removed and the title stops updating. The last written title is left as-is + (see Notes on possible future options). +- **SSR-safe.** When `typeof document === 'undefined'` the extension no-ops, so + subscribing and updating never throw on the server. + +### Example + +```ts +import { atom, computed } from '@reatom/core' +import { withDocumentTitle } from '#reatom/extension/with-document-title' + +const pageLabel = atom('Home', 'pageLabel') + +const title = computed(() => `${pageLabel()} | My App`, 'title').extend( + withDocumentTitle(), +) + +// The sync is active only while `title` has subscribers. +const unsub = title.subscribe(() => {}) +// document.title === 'Home | My App' + +pageLabel.set('Settings') +// document.title === 'Settings | My App' + +unsub() +// Further changes no longer touch document.title. +``` + +### Notes + +- `v1` intentionally takes no options. A future version could add opt-in + behaviors such as restoring the previous `document.title` on disconnect. diff --git a/src/reusables/document-title/with-document-title.meta.ts b/src/reusables/document-title/with-document-title.meta.ts new file mode 100644 index 0000000..84eca43 --- /dev/null +++ b/src/reusables/document-title/with-document-title.meta.ts @@ -0,0 +1,13 @@ +import type { RegistryItem } from 'jsrepo' + +export const withDocumentTitle = { + name: 'withDocumentTitle', + type: 'reatom:extension', + files: [ + { path: './with-document-title.ts' }, + { path: './with-document-title.md', role: 'doc' }, + { path: './with-document-title.node.test.ts', role: 'test' }, + { path: './with-document-title.browser.test.ts', role: 'test' }, + { path: './with-document-title.example.ts', role: 'example' }, + ], +} satisfies RegistryItem diff --git a/src/reusables/document-title/with-document-title.node.test.ts b/src/reusables/document-title/with-document-title.node.test.ts new file mode 100644 index 0000000..ac6b110 --- /dev/null +++ b/src/reusables/document-title/with-document-title.node.test.ts @@ -0,0 +1,24 @@ +import { assert, atom, computed } from '@reatom/core' +import { describe, expect, test } from 'test' + +import { withDocumentTitle } from './with-document-title' + +describe('withDocumentTitle', () => { + assert( + typeof document === 'undefined', + 'This test should run in a Node environment', + ) + + test('does not crash without document (SSR)', () => { + const label = atom('Home', 'label') + const title = computed(() => `${label()} | App`, 'title').extend( + withDocumentTitle(), + ) + + const unsub = title.subscribe(() => {}) + label.set('Settings') + + expect(title()).toBe('Settings | App') + unsub() + }) +}) diff --git a/src/reusables/document-title/with-document-title.ts b/src/reusables/document-title/with-document-title.ts new file mode 100644 index 0000000..bb01bd5 --- /dev/null +++ b/src/reusables/document-title/with-document-title.ts @@ -0,0 +1,34 @@ +import { + addChangeHook, + withConnectHook, + type AtomLike, + type Ext, +} from '@reatom/core' + +/** + * Atom extension that mirrors a string atom's value into `document.title`. + * + * The sync is connect-gated: it starts on the first subscriber (setting the + * title immediately), tracks further changes while connected, and stops on + * disconnect. When nothing subscribes, `document.title` is left untouched. Safe + * on the server — it no-ops when `document` is undefined. + * + * Applicable to any `AtomLike`, including a `computed` (the primary use + * case: derive a title, then extend it with `withDocumentTitle()`). + * + * @example + * const title = computed(() => `${pageLabel()} | My App`, 'title').extend( + * withDocumentTitle(), + * ) + * // document.title stays untouched until something subscribes to `title`. + */ +export const withDocumentTitle = < + Target extends AtomLike, +>(): Ext => + withConnectHook((target) => { + if (typeof document === 'undefined') return + document.title = target() + return addChangeHook(target, (title) => { + document.title = title + }) + })