/** * Universal state, the client half: serializable atoms round-trip (#47), and * `mount` finds the state script the server embedded and seeds atoms + the query * cache before the first render (#48, #49) — automatically, with no hydration * API in user code. These tests forge the script the `/ssr` `renderToStream` * appends; the full server → client round-trip lives in * `../server/render-to-stream.test.tsx`. */ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Equal from 'effect/Equal'; import * as Layer from 'effect/Layer'; import * as Schema from 'effect/Schema'; import { atom, mount, query, rec } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); const flush = (): Promise => act(async () => { await Promise.resolve(); await Promise.resolve(); }); /** Forge the state script `renderToStream` appends after the HTML. */ const embedStateScript = (state: object): HTMLScriptElement => { const script = document.createElement('script'); script.type = 'application/json'; script.setAttribute('data-effract-state', ''); script.textContent = JSON.stringify(state); document.body.appendChild(script); return script; }; let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { container.remove(); for (const script of Array.from(document.querySelectorAll('script[data-effract-state]'))) { script.remove(); } }); describe('serializable atoms + automatic hydration (#47, #48, #49)', () => { it('a serializable atom round-trips encode → decode preserving Equal (#47)', () => { const cell = atom.serializable('rt47', { n: 0 }, Schema.Struct({ n: Schema.Number })); cell.set({ n: 5 }); const encoded = cell.encode(); cell.set({ n: 0 }); // diverge cell.hydrate(encoded); // decode back expect(Equal.equals(cell.value, { n: 5 })).toBe(true); }); it('seeds a service-held atom the script arrived before — read on the first render (#48)', async () => { // The atom does not exist yet when the state script is read: it is created by // the service's `make`, resolved deeper in the same render pass. The seeded // value must still win over the initial — order-independence is what makes // hydration automatic. embedStateScript({ atoms: { 'h48-prefs': { n: 42 } } }); class Settings extends Context.Service()('hydration-test/Settings', { make: Effect.sync(() => ({ prefs: atom.serializable('h48-prefs', { n: 0 }, Schema.Struct({ n: Schema.Number })), })), }) { static readonly layer = Layer.effect(Settings, Settings.make); } const View = rec(function* () { const settings = yield* Settings; const value = yield* settings.prefs; return {value.n}; }); const root = createRoot(container); await act(async () => root.render(mount(Settings.layer, View))); // The very first render already reads the seeded value — no flash of 0. expect(container.querySelector('[data-x="n"]')?.textContent).toBe('42'); await act(async () => root.unmount()); }); it('seeds a query from the script — renders without suspending or fetching (#49)', async () => { embedStateScript({ queries: [{ key: 'q49', value: 'server-value' }] }); let fetched = 0; const load = Effect.promise(() => { fetched += 1; return Promise.resolve('client-value'); }); const View = rec(function* () { const value = yield* query(load, 'q49'); return {value}; }).suspense(loading); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, View))); // No loading frame: the server-resolved value renders immediately, and the // client effect never ran. expect(container.textContent).not.toContain('loading'); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('server-value'); await flush(); expect(fetched).toBe(0); await act(async () => root.unmount()); }); it('is a no-op on a fresh CSR — no script, initial values render', async () => { const cell = atom.serializable('csr-noop', 'initial', Schema.String); const View = rec(function* () { const value = yield* cell; return {value}; }); const root = createRoot(container); await act(async () => root.render(mount(Layer.empty, View))); expect(container.querySelector('[data-x="v"]')?.textContent).toBe('initial'); await act(async () => root.unmount()); }); it('a second mount does not re-seed atoms over values the user changed since', async () => { const script = embedStateScript({ atoms: { 'no-clobber': 'server' } }); const cell = atom.serializable('no-clobber', 'initial', Schema.String); const View = rec(function* () { const value = yield* cell; return {value}; }); const rootA = createRoot(container); await act(async () => rootA.render(mount(Layer.empty, View))); expect(cell.value).toBe('server'); // first mount seeded it await act(async () => cell.set('user-changed')); const containerB = document.createElement('div'); document.body.appendChild(containerB); const rootB = createRoot(containerB); await act(async () => rootB.render(mount(Layer.empty, View))); // The same script must not clobber post-hydration state. expect(cell.value).toBe('user-changed'); await act(async () => rootA.unmount()); await act(async () => rootB.unmount()); containerB.remove(); script.remove(); }); });