Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/reusables/document-title/with-document-title.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
25 changes: 25 additions & 0 deletions src/reusables/document-title/with-document-title.example.ts
Original file line number Diff line number Diff line change
@@ -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'
57 changes: 57 additions & 0 deletions src/reusables/document-title/with-document-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# withDocumentTitle

Atom extension that mirrors a string atom's value into `document.title`.

## `withDocumentTitle()`

Applies to any `AtomLike<string>` (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.
13 changes: 13 additions & 0 deletions src/reusables/document-title/with-document-title.meta.ts
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions src/reusables/document-title/with-document-title.node.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
34 changes: 34 additions & 0 deletions src/reusables/document-title/with-document-title.ts
Original file line number Diff line number Diff line change
@@ -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<string>`, 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<string>,
>(): Ext<Target> =>
withConnectHook<Target>((target) => {
if (typeof document === 'undefined') return
document.title = target()
return addChangeHook(target, (title) => {
document.title = title
})
})
Loading