diff --git a/.changeset/add-marko-store.md b/.changeset/add-marko-store.md new file mode 100644 index 00000000..da19fc7d --- /dev/null +++ b/.changeset/add-marko-store.md @@ -0,0 +1,5 @@ +--- +"@tanstack/marko-store": minor +--- + +Add `@tanstack/marko-store`, a Marko 6 adapter for TanStack Store. It provides the `` and `` tags — the Marko equivalents of the `useSelector` / `useStore` adapters — and re-exports the full `@tanstack/store` core. diff --git a/.gitignore b/.gitignore index 182bead0..eecb8443 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ examples/angular/store-context/.angular/cache/21.2.7/store-context/angular-compi examples/angular/stores/.angular/cache/21.2.7/stores/.tsbuildinfo examples/angular/stores/.angular/cache/21.2.7/stores/angular-compiler.db examples/angular/stores/.angular/cache/21.2.7/stores/angular-compiler.db-lock +test-results/ +*.tsbuildinfo +packages/marko-store/e2e/pw-results.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index c4d3dad5..c324b915 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,6 @@ "semi": false, "singleQuote": true, "trailingComma": "all", - "plugins": ["prettier-plugin-svelte"], + "plugins": ["prettier-plugin-svelte", "prettier-plugin-marko"], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } diff --git a/docs/config.json b/docs/config.json index 3a34004f..4297029e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -85,6 +85,15 @@ "to": "framework/lit/quick-start" } ] + }, + { + "label": "marko", + "children": [ + { + "label": "Quick Start - Marko", + "to": "framework/marko/quick-start" + } + ] } ] }, @@ -159,6 +168,12 @@ "to": "framework/lit/reference/index" } ] + }, + { + "label": "marko", + "children": [ + { "label": "Marko Tags", "to": "framework/marko/reference/index" } + ] } ] }, @@ -507,6 +522,16 @@ "to": "framework/lit/reference/interfaces/UseSelectorOptions" } ] + }, + { + "label": "marko", + "children": [ + { "label": "", "to": "framework/marko/reference/tags/store-provider" }, + { "label": "", "to": "framework/marko/reference/tags/store-context" }, + { "label": "", "to": "framework/marko/reference/tags/store-selector" }, + { "label": "", "to": "framework/marko/reference/tags/store-atom" }, + { "label": "", "to": "framework/marko/reference/tags/stream-store-provider" } + ] } ] }, @@ -672,6 +697,16 @@ "to": "framework/lit/examples/simple" } ] + }, + { + "label": "marko", + "children": [ + { "label": "Simple", "to": "framework/marko/examples/simple" }, + { "label": "Atoms", "to": "framework/marko/examples/atoms" }, + { "label": "Stores", "to": "framework/marko/examples/stores" }, + { "label": "Store Actions", "to": "framework/marko/examples/store-actions" }, + { "label": "Store Context", "to": "framework/marko/examples/store-context" } + ] } ] } diff --git a/docs/framework/marko/quick-start.md b/docs/framework/marko/quick-start.md new file mode 100644 index 00000000..6a893b61 --- /dev/null +++ b/docs/framework/marko/quick-start.md @@ -0,0 +1,88 @@ +--- +title: Quick Start +id: quick-start +--- + +The basic Marko app example to get started with TanStack `marko-store`. + +Marko exposes the store through tags rather than hooks. `` reads a slice of a store and keeps it live, re-rendering only when that selected slice changes. State is updated with `store.setState`, exactly as in the other adapters. + +```marko +import { createStore } from '@tanstack/marko-store' + +// You can instantiate a Store outside of Marko templates too! +export const store = createStore({ + dogs: 0, + cats: 0, +}) + +
+

How many of your friends like cats or dogs?

+

+ Press one of the buttons to add a counter of how many of your friends + like cats or dogs. +

+ + + // subscribes only to state.dogs + store selector=(state) => state.dogs/> +
dogs: ${dogs}
+ + + store selector=(state) => state.cats/> +
cats: ${cats}
+
+``` + +`` binds the latest selected value to its tag variable (here `dogs` and `cats`) and only updates when that specific selection changes. + +## A writable value: `` + +For a single writable value, `` gives two-way access to an atom — read its tag variable, and assign to it to write back: + +```marko +import { createAtom } from '@tanstack/marko-store' +export const countAtom = createAtom(0) + + countAtom/> + +``` + +## Sharing stores without imports: `` and `` + +To distribute one or more stores down the tree without importing them everywhere, wrap a subtree in `` with a bundle of stores, read any value below with `` in context mode, and grab the bundle to write with ``: + +```marko +import { createStore } from '@tanstack/marko-store' + + ({ cats: createStore({ count: 0 }) })> + c.cats selector=(s) => s.count/> +
cats: ${catCount}
+ + + +
+``` + +## Server rendering and streaming + +`marko-store` is resumable: a store rendered on the server stays live on the client after resume, with no re-execution. For server-computed, per-request data that arrives mid-render through ``, create the store from the awaited value inside the await using ``. The value then renders on the server with no first-paint flash and the store stays live after resume: + +```marko +import { createStore } from '@tanstack/marko-store' + + + ({ user: createStore(user) })> + c.user selector=(s) => s.name/> +
${name}
+
+
+``` + +Use a plain `` for in-order streaming, or wrap it in `` with a `<@placeholder>` for out-of-order streaming. Each `` needs a distinct `key`. + +For production SSR, use [`@marko/run`](https://github.com/marko-js/run), Marko's official server framework. Standalone `@marko/vite` SSR is intended for development only. diff --git a/docs/framework/marko/reference/index.md b/docs/framework/marko/reference/index.md new file mode 100644 index 00000000..6f0cb204 --- /dev/null +++ b/docs/framework/marko/reference/index.md @@ -0,0 +1,20 @@ +--- +id: "@tanstack/marko-store" +title: "@tanstack/marko-store" +--- + +# @tanstack/marko-store + +The Marko adapter exposes its API as custom tags. Each tag is documented on its own page below. + +## Tags + +- [``](tags/store-provider.md) +- [``](tags/store-context.md) +- [``](tags/store-selector.md) +- [``](tags/store-atom.md) +- [``](tags/stream-store-provider.md) + +## Re-exports + +`@tanstack/marko-store` re-exports the full `@tanstack/store` core, including `createStore`, `createAtom`, `Store`, `Derived`, and `shallow`. These are framework-agnostic; see the Store API Reference for their documentation. diff --git a/docs/framework/marko/reference/tags/store-atom.md b/docs/framework/marko/reference/tags/store-atom.md new file mode 100644 index 00000000..05211a79 --- /dev/null +++ b/docs/framework/marko/reference/tags/store-atom.md @@ -0,0 +1,29 @@ +--- +id: store-atom +title: "" +--- + +# Tag: `` + +Binds a single writable atom from `createAtom()` as a two-way value: it reads the atom's current value and writes back through the atom's `set`. Use it for a standalone writable value; for a `Store` (from `createStore`), read it with `` instead. + +```marko + qtyAtom> +

Quantity: ${qty}

+``` + +## Type parameters + +- **`T`** — the atom's value type. + +## Attributes + +- **`from`** — `() => { get(); set(); subscribe() }` (required). A thunk returning the atom (from `createAtom()`). The atom must have a `set` method; a `Store` does not and will throw. + +## Tag variable + +The atom's current value, typed `T`. The tag exposes a `value`/`valueChange` pair, so it is two-way bindable — bind it to a variable with `value:=` and assignments write back through the atom's `set`. + +## Behavior + +The value is seeded from `from().get()` at render and kept live by a subscription on mount, so it updates whenever the atom changes from anywhere. Writes go through `from().set(next)`. If `from()` returns something without a `set` method (such as a `Store`), the tag throws with guidance to use ``. diff --git a/docs/framework/marko/reference/tags/store-context.md b/docs/framework/marko/reference/tags/store-context.md new file mode 100644 index 00000000..3caa527c --- /dev/null +++ b/docs/framework/marko/reference/tags/store-context.md @@ -0,0 +1,28 @@ +--- +id: store-context +title: "" +--- + +# Tag: `` + +Reads the store bundle provided by the nearest `` with a matching key, so a descendant can use the live stores directly (for example to dispatch actions). Binds a getter. + +```marko + + + +``` + +## Attributes + +- **`key`** — `string` (optional, default `"__tanstack_store_context"`). The context key to read. Must match the key the `` used. + +## Tag variable + +A getter function, `() => bundle`. Call it to read the current bundle parked by the provider. It is a thunk rather than the bundle itself so the read happens at call time, after the provider has parked the bundle. + +## Behavior + +At render the tag checks that a bundle exists on `$global` under the key and throws if none is found — that is, when there is no `` above it. It does not subscribe to anything; use `` when you need a value that re-renders on change. diff --git a/docs/framework/marko/reference/tags/store-provider.md b/docs/framework/marko/reference/tags/store-provider.md new file mode 100644 index 00000000..d2674e82 --- /dev/null +++ b/docs/framework/marko/reference/tags/store-provider.md @@ -0,0 +1,28 @@ +--- +id: store-provider +title: "" +--- + +# Tag: `` + +Creates a bundle of stores for the current request and makes it available to descendant tags by a context key. Wraps the content it is given. + +```marko + ({ cart: createStore({ items: [] }) })> + + +``` + +## Attributes + +- **`value`** — `() => Record` (required). A thunk returning the bundle of named stores or atoms to provide. It is called once on the server at render and again on the client at mount, so each environment gets its own live instances. +- **`key`** — `string` (optional, default `"__tanstack_store_context"`). The context key the bundle is parked under. Use distinct keys to provide more than one bundle on a page. The matching `` and context-mode `` must use the same key. +- **`content`** — `Marko.Body` (optional). The body rendered inside the provider, usually supplied implicitly as the tag's children. + +## Tag variable + +None. `` is a wrapper: it renders its content and binds no tag variable. + +## Behavior + +The bundle is parked on `$global` under the context key at render, and re-created and published on mount so client subscribers attach to live instances. Only one provider may claim a given key: a second `` (or a ``) on the same key throws, to prevent two writers silently clobbering the same box. On destroy the box and its owner marker are cleared. diff --git a/docs/framework/marko/reference/tags/store-selector.md b/docs/framework/marko/reference/tags/store-selector.md new file mode 100644 index 00000000..4655b559 --- /dev/null +++ b/docs/framework/marko/reference/tags/store-selector.md @@ -0,0 +1,38 @@ +--- +id: store-selector +title: "" +--- + +# Tag: `` + +Subscribes to a store or atom, projects a slice of it, and re-renders when that slice changes. Works in two modes: `from` (a store passed directly) or `context` (a member of the provided bundle). Binds the selected value. + +```marko + + cartStore selector=(s) => s.items.length> +

${count} items

+ + + b.cart selector=(s) => s.items.length> +``` + +## Type parameters + +- **`TSource`** — the snapshot type of the source store or atom. +- **`TSelected`** — the projected slice type. Defaults to `TSource` when no `selector` is given. + +## Attributes + +- **`from`** — `() => { get(); subscribe() }` (optional). A thunk returning the source store or atom directly. Mutually exclusive with `context`. +- **`context`** — `(bundle) => { get(); subscribe() }` (optional). Selects the source from the provided bundle. Mutually exclusive with `from`. Annotate the parameter in typed projects, e.g. `context=(b: any) => b.cart`. +- **`key`** — `string` (optional, default `"__tanstack_store_context"`). The context key to read in `context` mode. Must match the provider's key. +- **`selector`** — `(snapshot: TSource) => TSelected` (optional, default identity). Projects the slice to track. +- **`compare`** — `(a: TSelected, b: TSelected) => boolean` (optional, default `===`). Equality check used to drop redundant updates. + +## Tag variable + +The current selected value, typed `TSelected | undefined`. It is `undefined` only at the brief moment on resume before a context-mode provider has parked its bundle; the tag recovers automatically and updates once the bundle is available. + +## Behavior + +Passing both `from` and `context` throws. The value is seeded at render (null-tolerant in context mode), then kept live by a subscription established on mount. In context mode the tag also listens on the internal publish bus, so it attaches to the store the moment the provider parks it during streaming or resume. Changing the source or swapping the `selector` re-projects; `compare` gates only store mutations, never a deliberate selector change. diff --git a/docs/framework/marko/reference/tags/stream-store-provider.md b/docs/framework/marko/reference/tags/stream-store-provider.md new file mode 100644 index 00000000..65ae8c69 --- /dev/null +++ b/docs/framework/marko/reference/tags/stream-store-provider.md @@ -0,0 +1,31 @@ +--- +id: stream-store-provider +title: "" +--- + +# Tag: `` + +A provider for progressive streaming: it creates a per-request store bundle from data that becomes available inside a late ``, and keeps it live across the server-to-client boundary. Place it inside the awaited body so the awaited value is captured into `value`. Wraps its content. + +```marko + + ({ order: createStore(order) })> + b.order selector=(o) => o.total> +

Total: ${total}

+
+
+``` + +## Attributes + +- **`value`** — `() => Record` (required). A thunk returning the bundle of stores or atoms, built from the awaited per-request data. Called on the server at render and again on the client at mount. +- **`key`** — `string` (**required**). The context key the bundle is parked under. Unlike ``, there is no default; each streamed provider needs an explicit, distinct key. +- **`content`** — `Marko.Body` (optional). The body rendered inside the provider. + +## Tag variable + +None. Like ``, it renders its content and binds no tag variable. + +## Behavior + +This is the "born-with-data" transport. The awaited value is captured by `value` and crosses to the client through the await subtree's resume scope; the bundle is built into `$global` under the key on the server, and rebuilt and published on mount so client subscribers attach to live instances. It shares ``'s owner marker, so a `` and a `` on the same key detect each other and throw rather than clobber. For production, a streaming Marko app must run under `@marko/run`; a standalone `@marko/vite` production build does not coordinate resume correctly. diff --git a/examples/marko/README.md b/examples/marko/README.md new file mode 100644 index 00000000..0f77e254 --- /dev/null +++ b/examples/marko/README.md @@ -0,0 +1,25 @@ +# Marko examples for TanStack Store + +These mirror the sibling adapter examples (react, svelte, …) using +`@tanstack/marko-store`. + +Five client-only parity examples — `stores`, `simple`, `atoms`, `store-actions`, +`store-context` — match the scenarios the other frameworks ship. Each is a +browser-only SPA: `vite.config.ts` uses `marko({ linked: false })` (Marko + Vite +is SSR-first, so a client-only build needs `linked: false`), and `src/index.ts` +mounts the app. + +Three Marko-specific SSR examples — `ssr-resume`, `ssr-per-request-multi`, +`streaming` — show server render + resume, per-request data rebuilt on the +client, and streaming a store into a late `` (born-with-data, in-order and +out-of-order). Each runs a small Marko Vite dev server (`node server.mjs`); open +the routes named in its README. + +Those three SSR servers are development demonstrations, not deployable production +servers. Real Marko production uses `@marko/run`; a dedicated `@marko/run` +example will be added separately. + +In this monorepo you do not install per example. Add `examples/marko/**` to +`pnpm-workspace.yaml`, run `pnpm install` once at the root, then start an example +by folder (`cd examples/marko/ && pnpm dev`) or by name +(`pnpm --filter @tanstack/store-example-marko- dev`). diff --git a/examples/marko/atoms/README.md b/examples/marko/atoms/README.md new file mode 100644 index 00000000..9e4e00b7 --- /dev/null +++ b/examples/marko/atoms/README.md @@ -0,0 +1,10 @@ +# Marko Store — atoms + +A module-level `createAtom`. The read-only total uses `` (no +selector function = the whole value); the editable count uses ``, +whose binding writes back through `atom.set`. Reset calls `atom.set(0)`. + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/atoms/index.html b/examples/marko/atoms/index.html new file mode 100644 index 00000000..f3f476c5 --- /dev/null +++ b/examples/marko/atoms/index.html @@ -0,0 +1,12 @@ + + + + + + Marko Store Example — atoms + + +
+ + + diff --git a/examples/marko/atoms/package.json b/examples/marko/atoms/package.json new file mode 100644 index 00000000..fc93ff83 --- /dev/null +++ b/examples/marko/atoms/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/store-example-marko-atoms", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3061", + "build": "vite build", + "preview": "vite preview", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/atoms/src/App.marko b/examples/marko/atoms/src/App.marko new file mode 100644 index 00000000..b5cb22ad --- /dev/null +++ b/examples/marko/atoms/src/App.marko @@ -0,0 +1,20 @@ +import { countAtom } from './atom' + +
+

Marko Atoms

+

This example creates a module-level atom and reads and updates it with the tags.

+ + + countAtom)/> +

Total: ${total}

+ +
+ + +
+ + + countAtom)/> +

Editable count: ${count}

+ +
diff --git a/examples/marko/atoms/src/atom.ts b/examples/marko/atoms/src/atom.ts new file mode 100644 index 00000000..79fa57fc --- /dev/null +++ b/examples/marko/atoms/src/atom.ts @@ -0,0 +1,4 @@ +import { createAtom } from '@tanstack/marko-store' + +// You can create atoms outside of components, at module scope. +export const countAtom = createAtom(0) diff --git a/examples/marko/atoms/src/index.ts b/examples/marko/atoms/src/index.ts new file mode 100644 index 00000000..21867a93 --- /dev/null +++ b/examples/marko/atoms/src/index.ts @@ -0,0 +1,4 @@ +import App from './App.marko' + +const el = document.getElementById('app') +if (el) App.mount({}, el) diff --git a/examples/marko/atoms/tsconfig.json b/examples/marko/atoms/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/atoms/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/atoms/vite.config.ts b/examples/marko/atoms/vite.config.ts new file mode 100644 index 00000000..ac3ced6c --- /dev/null +++ b/examples/marko/atoms/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import marko from '@marko/vite' + +// linked: false => browser-only build (a client-only SPA, mounted in src/index.ts). +export default defineConfig({ + plugins: [marko({ linked: false })], +}) diff --git a/examples/marko/simple/README.md b/examples/marko/simple/README.md new file mode 100644 index 00000000..5dd074e6 --- /dev/null +++ b/examples/marko/simple/README.md @@ -0,0 +1,10 @@ +# Marko Store — simple + +A multi-component counter split across files. `Increment.marko` writes with +`store.setState`; `Display.marko` reads a slice with ``; +`App.marko` composes them around a module-level store in `store.ts`. + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/simple/index.html b/examples/marko/simple/index.html new file mode 100644 index 00000000..0d0ff6ac --- /dev/null +++ b/examples/marko/simple/index.html @@ -0,0 +1,12 @@ + + + + + + Marko Store Example — simple + + +
+ + + diff --git a/examples/marko/simple/package.json b/examples/marko/simple/package.json new file mode 100644 index 00000000..ce367547 --- /dev/null +++ b/examples/marko/simple/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/store-example-marko-simple", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3060", + "build": "vite build", + "preview": "vite preview", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/simple/src/App.marko b/examples/marko/simple/src/App.marko new file mode 100644 index 00000000..50d72bf4 --- /dev/null +++ b/examples/marko/simple/src/App.marko @@ -0,0 +1,11 @@ +import Increment from './Increment.marko' +import Display from './Display.marko' + +
+

How many of your friends like cats or dogs?

+

Press a button to add a counter of how many of your friends like cats or dogs.

+ + + + +
diff --git a/examples/marko/simple/src/Display.marko b/examples/marko/simple/src/Display.marko new file mode 100644 index 00000000..68449c2c --- /dev/null +++ b/examples/marko/simple/src/Display.marko @@ -0,0 +1,9 @@ +import { store } from './store' + +export interface Input { + animal: 'dogs' | 'cats' +} + +// Re-renders only when state[animal] changes; an unrelated change won't re-render it. + store) selector=((s: { dogs: number; cats: number }) => s[input.animal])/> +
${input.animal}: ${count}
diff --git a/examples/marko/simple/src/Increment.marko b/examples/marko/simple/src/Increment.marko new file mode 100644 index 00000000..3d771c5d --- /dev/null +++ b/examples/marko/simple/src/Increment.marko @@ -0,0 +1,11 @@ +import { store } from './store' + +export interface Input { + animal: 'dogs' | 'cats' +} + + diff --git a/examples/marko/simple/src/index.ts b/examples/marko/simple/src/index.ts new file mode 100644 index 00000000..21867a93 --- /dev/null +++ b/examples/marko/simple/src/index.ts @@ -0,0 +1,4 @@ +import App from './App.marko' + +const el = document.getElementById('app') +if (el) App.mount({}, el) diff --git a/examples/marko/simple/src/store.ts b/examples/marko/simple/src/store.ts new file mode 100644 index 00000000..d3cc5d82 --- /dev/null +++ b/examples/marko/simple/src/store.ts @@ -0,0 +1,7 @@ +import { createStore } from '@tanstack/marko-store' + +// You can instantiate a Store outside of components, at module scope. +export const store = createStore({ + dogs: 0, + cats: 0, +}) diff --git a/examples/marko/simple/tsconfig.json b/examples/marko/simple/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/simple/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/simple/vite.config.ts b/examples/marko/simple/vite.config.ts new file mode 100644 index 00000000..ac3ced6c --- /dev/null +++ b/examples/marko/simple/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import marko from '@marko/vite' + +// linked: false => browser-only build (a client-only SPA, mounted in src/index.ts). +export default defineConfig({ + plugins: [marko({ linked: false })], +}) diff --git a/examples/marko/ssr-per-request-multi/README.md b/examples/marko/ssr-per-request-multi/README.md new file mode 100644 index 00000000..fb643d6a --- /dev/null +++ b/examples/marko/ssr-per-request-multi/README.md @@ -0,0 +1,15 @@ +# Marko Store — ssr-per-request-multi + +Two pages. The home page (`/`) rebuilds a store from per-request DATA passed by +the route: the data crosses in the serialized payload and the store is rebuilt on +each side. The `/multi` page parks a bundle of two stores in one provider, read +independently, with a context write targeting one. + +This is a development demonstration, run with the Marko Vite dev server. It is +NOT a deployable production server — real Marko production uses `@marko/run`. + +To run this example: + +- `npm install` +- `npm run dev` +- open `/` and `/multi` diff --git a/examples/marko/ssr-per-request-multi/package.json b/examples/marko/ssr-per-request-multi/package.json new file mode 100644 index 00000000..6e22fe54 --- /dev/null +++ b/examples/marko/ssr-per-request-multi/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/store-example-marko-ssr-per-request-multi", + "private": true, + "type": "module", + "scripts": { + "dev": "node server.mjs", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/ssr-per-request-multi/server.mjs b/examples/marko/ssr-per-request-multi/server.mjs new file mode 100644 index 00000000..1967cf08 --- /dev/null +++ b/examples/marko/ssr-per-request-multi/server.mjs @@ -0,0 +1,47 @@ +import { createServer as createHttpServer } from 'node:http' +import { createRequire } from 'node:module' +import path from 'node:path' +import url from 'node:url' + +import { createServer as createViteServer } from 'vite' + +const require = createRequire(import.meta.url) +const marko = require('@marko/vite').default +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) +const PORT = 5201 + +// A small Vite dev server in middleware mode renders the .marko pages and injects the browser +// scripts so the page resumes. NOTE: this is a development demonstration, not a deployable +// production server. Real Marko production uses @marko/run. + +// Create the HTTP server first so Vite can attach its HMR websocket to the same server/port +// (otherwise the browser's HMR client tries to open a socket that isn't there). +const httpServer = createHttpServer() + +const devServer = await createViteServer({ + root: __dirname, + configFile: false, + appType: 'custom', + logLevel: 'warn', + plugins: [marko()], + server: { middlewareMode: true, hmr: { server: httpServer } }, +}) + +devServer.middlewares.use((req, res, next) => { + if (req.url === '/favicon.ico') { res.statusCode = 204; res.end(); return } + next() +}) +devServer.middlewares.use(async (req, res, next) => { + try { + const { handler } = await devServer.ssrLoadModule(path.join(__dirname, './src/index.ts')) + await handler(req, res, next) + } catch (err) { + devServer.ssrFixStacktrace(err) + next(err) + } +}) + +httpServer.on('request', devServer.middlewares) +httpServer.listen(PORT, () => { + console.log('example server on http://localhost:' + PORT) +}) diff --git a/examples/marko/ssr-per-request-multi/src/index.ts b/examples/marko/ssr-per-request-multi/src/index.ts new file mode 100644 index 00000000..f6698a32 --- /dev/null +++ b/examples/marko/ssr-per-request-multi/src/index.ts @@ -0,0 +1,32 @@ +import perreqPage from './perreq-page.marko' +import multiPage from './multi-page.marko' + +// Minimal structural types for the Node req/res the dev server passes in — keeps this small +// example free of an @types/node dependency. The real Node objects satisfy these structurally. +interface Req { url?: string } +interface Res { + statusCode: number + headersSent: boolean + setHeader(name: string, value: string): void + write(chunk: string): void + end(data?: string): void +} + +type ServerTemplate = { render: (input: Record) => AsyncIterable } + +const routes: Record }> = { + '/': { page: perreqPage as unknown as ServerTemplate, input: { initial: { count: 42 } } }, + '/multi': { page: multiPage as unknown as ServerTemplate, input: {} }, +} + +export async function handler(req: Req, res: Res, next?: () => void) { + const url = (req.url ?? '/').split('?')[0] ?? '/' + const route = routes[url] + if (!route) { next?.(); return } + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html; charset=utf-8') + // Fresh $global per request — never share the resume/data box across requests. + const input = { ...route.input, $global: {} } + for await (const chunk of route.page.render(input)) res.write(chunk) + res.end() +} diff --git a/examples/marko/ssr-per-request-multi/src/multi-page.marko b/examples/marko/ssr-per-request-multi/src/multi-page.marko new file mode 100644 index 00000000..9c7ce72d --- /dev/null +++ b/examples/marko/ssr-per-request-multi/src/multi-page.marko @@ -0,0 +1,28 @@ +import { a, b } from './stores' + + + + Multiple stores in one provider + +
+

Multiple stores in one provider

+

One provider parks a bundle of two stores. Each selector picks a different member; a write to one moves only its own selector.

+ + + +

Resumed in browser: ${resumed}

+ + ({ a, b })> + c.a) selector=((s: any) => s.count)/> + c.b) selector=((s: any) => s.count)/> +

A: ${countA}

+

B: ${countB}

+ + +
+ + + +
+ + diff --git a/examples/marko/ssr-per-request-multi/src/perreq-page.marko b/examples/marko/ssr-per-request-multi/src/perreq-page.marko new file mode 100644 index 00000000..1bbc072c --- /dev/null +++ b/examples/marko/ssr-per-request-multi/src/perreq-page.marko @@ -0,0 +1,28 @@ +import { createStore } from '@tanstack/marko-store' +import type { Store } from '@tanstack/marko-store' + +export interface Input { + initial: { count: number } +} + + + + Per-request data + +
+

Per-request data, rebuilt on resume

+

The route passes plain DATA (input.initial); the provider creates the store from it. The data crosses in the serialized payload and the store is rebuilt on the client — a live store is never serialized.

+ + + +

Resumed in browser: ${resumed}

+ + ({ counter: createStore(input.initial) })> + }) => c.counter) selector=((s: { count: number }) => s.count)/> +

Count: ${count}

+ + +
+
+ + diff --git a/examples/marko/ssr-per-request-multi/src/stores.ts b/examples/marko/ssr-per-request-multi/src/stores.ts new file mode 100644 index 00000000..6d55e079 --- /dev/null +++ b/examples/marko/ssr-per-request-multi/src/stores.ts @@ -0,0 +1,5 @@ +import { createStore } from '@tanstack/marko-store' + +// Two isolated module stores for the multi-store page. +export const a = createStore({ count: 5 }) +export const b = createStore({ count: 50 }) diff --git a/examples/marko/ssr-per-request-multi/tsconfig.json b/examples/marko/ssr-per-request-multi/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/ssr-per-request-multi/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/ssr-resume/README.md b/examples/marko/ssr-resume/README.md new file mode 100644 index 00000000..21178edd --- /dev/null +++ b/examples/marko/ssr-resume/README.md @@ -0,0 +1,14 @@ +# Marko Store — ssr-resume + +A server-rendered page: the server paints the store's value, then the page +resumes and the store stays live (external writes and the atom write work after +load). The store is a module singleton and is rebuilt on each side, so no live +store is ever serialized. + +This is a development demonstration, run with the Marko Vite dev server. It is +NOT a deployable production server — real Marko production uses `@marko/run`. + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/ssr-resume/package.json b/examples/marko/ssr-resume/package.json new file mode 100644 index 00000000..255a9889 --- /dev/null +++ b/examples/marko/ssr-resume/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/store-example-marko-ssr-resume", + "private": true, + "type": "module", + "scripts": { + "dev": "node server.mjs", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/ssr-resume/server.mjs b/examples/marko/ssr-resume/server.mjs new file mode 100644 index 00000000..cc0fedb0 --- /dev/null +++ b/examples/marko/ssr-resume/server.mjs @@ -0,0 +1,47 @@ +import { createServer as createHttpServer } from 'node:http' +import { createRequire } from 'node:module' +import path from 'node:path' +import url from 'node:url' + +import { createServer as createViteServer } from 'vite' + +const require = createRequire(import.meta.url) +const marko = require('@marko/vite').default +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) +const PORT = 5200 + +// A small Vite dev server in middleware mode renders the .marko pages and injects the browser +// scripts so the page resumes. NOTE: this is a development demonstration, not a deployable +// production server. Real Marko production uses @marko/run. + +// Create the HTTP server first so Vite can attach its HMR websocket to the same server/port +// (otherwise the browser's HMR client tries to open a socket that isn't there). +const httpServer = createHttpServer() + +const devServer = await createViteServer({ + root: __dirname, + configFile: false, + appType: 'custom', + logLevel: 'warn', + plugins: [marko()], + server: { middlewareMode: true, hmr: { server: httpServer } }, +}) + +devServer.middlewares.use((req, res, next) => { + if (req.url === '/favicon.ico') { res.statusCode = 204; res.end(); return } + next() +}) +devServer.middlewares.use(async (req, res, next) => { + try { + const { handler } = await devServer.ssrLoadModule(path.join(__dirname, './src/index.ts')) + await handler(req, res, next) + } catch (err) { + devServer.ssrFixStacktrace(err) + next(err) + } +}) + +httpServer.on('request', devServer.middlewares) +httpServer.listen(PORT, () => { + console.log('example server on http://localhost:' + PORT) +}) diff --git a/examples/marko/ssr-resume/src/index.ts b/examples/marko/ssr-resume/src/index.ts new file mode 100644 index 00000000..d2e8a280 --- /dev/null +++ b/examples/marko/ssr-resume/src/index.ts @@ -0,0 +1,30 @@ +import page from './page.marko' + +// Minimal structural types for the Node req/res the dev server passes in — keeps this small +// example free of an @types/node dependency. The real Node objects satisfy these structurally. +interface Req { url?: string } +interface Res { + statusCode: number + headersSent: boolean + setHeader(name: string, value: string): void + write(chunk: string): void + end(data?: string): void +} + +type ServerTemplate = { render: (input: Record) => AsyncIterable } + +const routes: Record }> = { + '/': { page: page as unknown as ServerTemplate, input: {} }, +} + +export async function handler(req: Req, res: Res, next?: () => void) { + const url = (req.url ?? '/').split('?')[0] ?? '/' + const route = routes[url] + if (!route) { next?.(); return } + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html; charset=utf-8') + // Fresh $global per request — never share the resume box across requests. + const input = { ...route.input, $global: {} } + for await (const chunk of route.page.render(input)) res.write(chunk) + res.end() +} diff --git a/examples/marko/ssr-resume/src/page.marko b/examples/marko/ssr-resume/src/page.marko new file mode 100644 index 00000000..ff83e21c --- /dev/null +++ b/examples/marko/ssr-resume/src/page.marko @@ -0,0 +1,24 @@ +import { counterStore, countAtom } from './store' + + + + SSR resume + +
+

Server render and resume

+

The server paints the store's values; the page then resumes and the store stays live in the browser.

+ + + +

Resumed in browser: ${resumed}

+ + counterStore) selector=((s: { count: number }) => s.count)/> +

Count: ${count}

+ + + countAtom)/> +

Atom: ${atom}

+ +
+ + diff --git a/examples/marko/ssr-resume/src/store.ts b/examples/marko/ssr-resume/src/store.ts new file mode 100644 index 00000000..77651d15 --- /dev/null +++ b/examples/marko/ssr-resume/src/store.ts @@ -0,0 +1,7 @@ +import { createAtom, createStore } from '@tanstack/marko-store' + +// Module-level store + atom. The page's inline thunks (() => counterStore) close over these +// stable references, which keeps the live store OUT of the serialized resume payload — the +// store is rebuilt on each side, never serialized. +export const counterStore = createStore({ count: 5 }) +export const countAtom = createAtom(7) diff --git a/examples/marko/ssr-resume/tsconfig.json b/examples/marko/ssr-resume/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/ssr-resume/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/store-actions/README.md b/examples/marko/store-actions/README.md new file mode 100644 index 00000000..48525189 --- /dev/null +++ b/examples/marko/store-actions/README.md @@ -0,0 +1,11 @@ +# Marko Store — store-actions + +A module-level store created with actions. Reads go through ``; +mutations call `store.actions.addCat` / `addDog` / `log`. There is no Marko +equivalent of the experimental `_useStore` tuple, and none is needed — holding +the store directly, `` plus `store.actions` cover the same ground. + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/store-actions/index.html b/examples/marko/store-actions/index.html new file mode 100644 index 00000000..571b8f86 --- /dev/null +++ b/examples/marko/store-actions/index.html @@ -0,0 +1,12 @@ + + + + + + Marko Store Example — store-actions + + +
+ + + diff --git a/examples/marko/store-actions/package.json b/examples/marko/store-actions/package.json new file mode 100644 index 00000000..e0bf4258 --- /dev/null +++ b/examples/marko/store-actions/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/store-example-marko-store-actions", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3062", + "build": "vite build", + "preview": "vite preview", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/store-actions/src/App.marko b/examples/marko/store-actions/src/App.marko new file mode 100644 index 00000000..2565f11a --- /dev/null +++ b/examples/marko/store-actions/src/App.marko @@ -0,0 +1,23 @@ +import { petStore } from './store' + +
+ +

Marko Store Actions

+

+ This example creates a module-level store with actions. Components read state + with a selector and mutate through store.actions. Marko has no equivalent of + the experimental _useStore tuple, and does not need one: you already hold the + store, so a selector for the read plus store.actions for the write cover it. +

+ + petStore) selector=(s => s.cats)/> +

Cats: ${cats}

+ + + petStore) selector=(s => s.dogs)/> +

Dogs: ${dogs}

+ + + petStore) selector=(s => s.cats + s.dogs)/> +

Total votes: ${total}

+
diff --git a/examples/marko/store-actions/src/index.ts b/examples/marko/store-actions/src/index.ts new file mode 100644 index 00000000..21867a93 --- /dev/null +++ b/examples/marko/store-actions/src/index.ts @@ -0,0 +1,4 @@ +import App from './App.marko' + +const el = document.getElementById('app') +if (el) App.mount({}, el) diff --git a/examples/marko/store-actions/src/store.ts b/examples/marko/store-actions/src/store.ts new file mode 100644 index 00000000..e84b6c5e --- /dev/null +++ b/examples/marko/store-actions/src/store.ts @@ -0,0 +1,11 @@ +import { createStore } from '@tanstack/marko-store' + +// A module-level store created WITH actions (the second argument). +export const petStore = createStore( + { cats: 0, dogs: 0 }, + ({ setState, get }) => ({ + addCat: () => setState((prev) => ({ ...prev, cats: prev.cats + 1 })), + addDog: () => setState((prev) => ({ ...prev, dogs: prev.dogs + 1 })), + log: () => console.log(get()), + }), +) diff --git a/examples/marko/store-actions/tsconfig.json b/examples/marko/store-actions/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/store-actions/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/store-actions/vite.config.ts b/examples/marko/store-actions/vite.config.ts new file mode 100644 index 00000000..ac3ced6c --- /dev/null +++ b/examples/marko/store-actions/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import marko from '@marko/vite' + +// linked: false => browser-only build (a client-only SPA, mounted in src/index.ts). +export default defineConfig({ + plugins: [marko({ linked: false })], +}) diff --git a/examples/marko/store-context/README.md b/examples/marko/store-context/README.md new file mode 100644 index 00000000..eaaa5ceb --- /dev/null +++ b/examples/marko/store-context/README.md @@ -0,0 +1,12 @@ +# Marko Store — store-context + +The flagship for the adapter's delivery trio. `` parks a bundle +of `{ votesStore, countAtom }`; nested components read the store with +``, read the atom the same way (and with `` +for read/write), and write through ``. Where the other frameworks +lean on their native context, Marko uses provider + context + selector. + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/store-context/index.html b/examples/marko/store-context/index.html new file mode 100644 index 00000000..f50c0d29 --- /dev/null +++ b/examples/marko/store-context/index.html @@ -0,0 +1,12 @@ + + + + + + Marko Store Example — store-context + + +
+ + + diff --git a/examples/marko/store-context/package.json b/examples/marko/store-context/package.json new file mode 100644 index 00000000..be7042b7 --- /dev/null +++ b/examples/marko/store-context/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/store-example-marko-store-context", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3063", + "build": "vite build", + "preview": "vite preview", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/store-context/src/App.marko b/examples/marko/store-context/src/App.marko new file mode 100644 index 00000000..4a7a73e9 --- /dev/null +++ b/examples/marko/store-context/src/App.marko @@ -0,0 +1,35 @@ +import { createStore, createAtom } from '@tanstack/marko-store' +import CatCard from './CatCard.marko' +import DogCard from './DogCard.marko' +import TotalCard from './TotalCard.marko' +import StoreButtons from './StoreButtons.marko' +import AtomSummary from './AtomSummary.marko' +import NestedAtomControls from './NestedAtomControls.marko' +import DeepAtomEditor from './DeepAtomEditor.marko' + +// Marko has no native context, so this example uses the adapter's delivery trio: +// parks a bundle, children read it with , +// and writes go through . (A real app would give the bundle a named type; +// the context pickers here use `any` to keep the example focused.) + + + + ({ votesStore, countAtom })> +
+

Marko Store Context

+

+ This example provides both a store and an atom through a single context + bundle, then reads them from nested components. +

+ + + + +
+

Nested Atom Components

+ + + +
+
+
diff --git a/examples/marko/store-context/src/AtomSummary.marko b/examples/marko/store-context/src/AtomSummary.marko new file mode 100644 index 00000000..13842cc8 --- /dev/null +++ b/examples/marko/store-context/src/AtomSummary.marko @@ -0,0 +1,3 @@ + + c.countAtom)/> +

Atom count: ${count}

diff --git a/examples/marko/store-context/src/CatCard.marko b/examples/marko/store-context/src/CatCard.marko new file mode 100644 index 00000000..f2267ecb --- /dev/null +++ b/examples/marko/store-context/src/CatCard.marko @@ -0,0 +1,2 @@ + c.votesStore) selector=((s: any) => s.cats)/> +

Cats: ${cats}

diff --git a/examples/marko/store-context/src/DeepAtomEditor.marko b/examples/marko/store-context/src/DeepAtomEditor.marko new file mode 100644 index 00000000..7f703302 --- /dev/null +++ b/examples/marko/store-context/src/DeepAtomEditor.marko @@ -0,0 +1,7 @@ + + + (ctx() as any).countAtom)/> +
+

Editable atom count: ${count}

+ +
diff --git a/examples/marko/store-context/src/DogCard.marko b/examples/marko/store-context/src/DogCard.marko new file mode 100644 index 00000000..0a1c2dd6 --- /dev/null +++ b/examples/marko/store-context/src/DogCard.marko @@ -0,0 +1,2 @@ + c.votesStore) selector=((s: any) => s.dogs)/> +

Dogs: ${dogs}

diff --git a/examples/marko/store-context/src/NestedAtomControls.marko b/examples/marko/store-context/src/NestedAtomControls.marko new file mode 100644 index 00000000..aacb2a78 --- /dev/null +++ b/examples/marko/store-context/src/NestedAtomControls.marko @@ -0,0 +1,5 @@ + +
+ + +
diff --git a/examples/marko/store-context/src/StoreButtons.marko b/examples/marko/store-context/src/StoreButtons.marko new file mode 100644 index 00000000..52b1de6b --- /dev/null +++ b/examples/marko/store-context/src/StoreButtons.marko @@ -0,0 +1,5 @@ + +
+ + +
diff --git a/examples/marko/store-context/src/TotalCard.marko b/examples/marko/store-context/src/TotalCard.marko new file mode 100644 index 00000000..f143f66b --- /dev/null +++ b/examples/marko/store-context/src/TotalCard.marko @@ -0,0 +1,2 @@ + c.votesStore) selector=((s: any) => s.cats + s.dogs)/> +

Total votes: ${total}

diff --git a/examples/marko/store-context/src/index.ts b/examples/marko/store-context/src/index.ts new file mode 100644 index 00000000..21867a93 --- /dev/null +++ b/examples/marko/store-context/src/index.ts @@ -0,0 +1,4 @@ +import App from './App.marko' + +const el = document.getElementById('app') +if (el) App.mount({}, el) diff --git a/examples/marko/store-context/tsconfig.json b/examples/marko/store-context/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/store-context/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/store-context/vite.config.ts b/examples/marko/store-context/vite.config.ts new file mode 100644 index 00000000..ac3ced6c --- /dev/null +++ b/examples/marko/store-context/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import marko from '@marko/vite' + +// linked: false => browser-only build (a client-only SPA, mounted in src/index.ts). +export default defineConfig({ + plugins: [marko({ linked: false })], +}) diff --git a/examples/marko/stores/README.md b/examples/marko/stores/README.md new file mode 100644 index 00000000..14dc412e --- /dev/null +++ b/examples/marko/stores/README.md @@ -0,0 +1,12 @@ +# Marko Store Example + +This example demonstrates: + +- `` +- `store.setState` +- module-level `Store` + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/marko/stores/index.html b/examples/marko/stores/index.html new file mode 100644 index 00000000..4f065f59 --- /dev/null +++ b/examples/marko/stores/index.html @@ -0,0 +1,12 @@ + + + + + + Marko Store Example + + +
+ + + diff --git a/examples/marko/stores/package.json b/examples/marko/stores/package.json new file mode 100644 index 00000000..6d82684c --- /dev/null +++ b/examples/marko/stores/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/store-example-marko-stores", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3070", + "build": "vite build", + "preview": "vite preview", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/stores/src/App.marko b/examples/marko/stores/src/App.marko new file mode 100644 index 00000000..3951a137 --- /dev/null +++ b/examples/marko/stores/src/App.marko @@ -0,0 +1,27 @@ +import { petStore } from './store' + +
+

Marko Store

+

+ This example creates a module-level store. Components read state with a + selector and update it directly with store.setState. +

+ + petStore) selector=(s => s.cats)/> +

Cats: ${cats}

+ + petStore) selector=(s => s.dogs)/> +

Dogs: ${dogs}

+ + petStore) selector=(s => s.cats + s.dogs)/> +

Total votes: ${total}

+ +
+ + +
+
diff --git a/examples/marko/stores/src/index.ts b/examples/marko/stores/src/index.ts new file mode 100644 index 00000000..21867a93 --- /dev/null +++ b/examples/marko/stores/src/index.ts @@ -0,0 +1,4 @@ +import App from './App.marko' + +const el = document.getElementById('app') +if (el) App.mount({}, el) diff --git a/examples/marko/stores/src/store.ts b/examples/marko/stores/src/store.ts new file mode 100644 index 00000000..c6fa9f56 --- /dev/null +++ b/examples/marko/stores/src/store.ts @@ -0,0 +1,7 @@ +import { createStore } from '@tanstack/marko-store' + +// You can create stores at module scope and import them wherever you need them. +export const petStore = createStore({ + cats: 0, + dogs: 0, +}) diff --git a/examples/marko/stores/tsconfig.json b/examples/marko/stores/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/stores/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/examples/marko/stores/vite.config.ts b/examples/marko/stores/vite.config.ts new file mode 100644 index 00000000..52bb3f3a --- /dev/null +++ b/examples/marko/stores/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import marko from '@marko/vite' + +// linked: false => browser-only build. The .marko is mounted on the client (src/index.ts), +// so there is no server pass. The SSR examples (ssr-resume etc.) use the default linked mode. +export default defineConfig({ + plugins: [marko({ linked: false })], +}) diff --git a/examples/marko/streaming/README.md b/examples/marko/streaming/README.md new file mode 100644 index 00000000..c4c889fe --- /dev/null +++ b/examples/marko/streaming/README.md @@ -0,0 +1,20 @@ +# Marko Store — streaming + +Streaming server-computed per-request data into a live store with +``. The home page (`/`) shows the in-order case: a store +born from a value that arrives inside a late ``, painted server-side with +no flash and live after resume. The `/out-of-order` page wraps each `` in +`` with a `<@placeholder>`, so the fast block paints before the slow one +while each store stays live. + +Note the server gives each request its own fresh `$global`: born-with-data parks +the store on `$global`, so requests must not share it. + +This is a development demonstration, run with the Marko Vite dev server. It is +NOT a deployable production server — real Marko production uses `@marko/run`. + +To run this example: + +- `npm install` +- `npm run dev` +- open `/` and `/out-of-order` diff --git a/examples/marko/streaming/package.json b/examples/marko/streaming/package.json new file mode 100644 index 00000000..198b1378 --- /dev/null +++ b/examples/marko/streaming/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tanstack/store-example-marko-streaming", + "private": true, + "type": "module", + "scripts": { + "dev": "node server.mjs", + "test:types": "marko-type-check -p tsconfig.json" + }, + "dependencies": { + "@tanstack/marko-store": "^0.11.0", + "marko": "^6" + }, + "devDependencies": { + "@marko/language-tools": "^2.6.0", + "@marko/type-check": "^3.1.0", + "@marko/vite": "^6", + "vite": "^8.0.8" + } +} diff --git a/examples/marko/streaming/server.mjs b/examples/marko/streaming/server.mjs new file mode 100644 index 00000000..1d4ad534 --- /dev/null +++ b/examples/marko/streaming/server.mjs @@ -0,0 +1,47 @@ +import { createServer as createHttpServer } from 'node:http' +import { createRequire } from 'node:module' +import path from 'node:path' +import url from 'node:url' + +import { createServer as createViteServer } from 'vite' + +const require = createRequire(import.meta.url) +const marko = require('@marko/vite').default +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) +const PORT = 5202 + +// A small Vite dev server in middleware mode renders the .marko pages and injects the browser +// scripts so the page resumes. NOTE: this is a development demonstration, not a deployable +// production server. Real Marko production uses @marko/run. + +// Create the HTTP server first so Vite can attach its HMR websocket to the same server/port +// (otherwise the browser's HMR client tries to open a socket that isn't there). +const httpServer = createHttpServer() + +const devServer = await createViteServer({ + root: __dirname, + configFile: false, + appType: 'custom', + logLevel: 'warn', + plugins: [marko()], + server: { middlewareMode: true, hmr: { server: httpServer } }, +}) + +devServer.middlewares.use((req, res, next) => { + if (req.url === '/favicon.ico') { res.statusCode = 204; res.end(); return } + next() +}) +devServer.middlewares.use(async (req, res, next) => { + try { + const { handler } = await devServer.ssrLoadModule(path.join(__dirname, './src/index.ts')) + await handler(req, res, next) + } catch (err) { + devServer.ssrFixStacktrace(err) + next(err) + } +}) + +httpServer.on('request', devServer.middlewares) +httpServer.listen(PORT, () => { + console.log('example server on http://localhost:' + PORT) +}) diff --git a/examples/marko/streaming/src/index.ts b/examples/marko/streaming/src/index.ts new file mode 100644 index 00000000..2f505c9d --- /dev/null +++ b/examples/marko/streaming/src/index.ts @@ -0,0 +1,44 @@ +import inorderPage from './inorder-page.marko' +import outoforderPage from './outoforder-page.marko' + +// Minimal structural types for the Node req/res the dev server passes in — keeps this small +// example free of an @types/node dependency. The real Node objects satisfy these structurally. +interface Req { url?: string } +interface Res { + statusCode: number + headersSent: boolean + setHeader(name: string, value: string): void + write(chunk: string): void + end(data?: string): void +} + +type ServerTemplate = { render: (input: Record) => AsyncIterable } + +const routes: Record }> = { + '/': { page: inorderPage as unknown as ServerTemplate, input: { value: 77 } }, + '/out-of-order': { page: outoforderPage as unknown as ServerTemplate, input: { slow: 33, fast: 44 } }, +} + +export async function handler(req: Req, res: Res, next?: () => void) { + const url = (req.url ?? '/').split('?')[0] ?? '/' + const route = routes[url] + if (!route) { next?.(); return } + res.setHeader('Content-Type', 'text/html; charset=utf-8') + // Fresh $global per request: born-with-data parks the store on $global, so each request MUST + // get its own box or concurrent requests would see each other's stores. + const input = { ...route.input, $global: {} } + try { + res.statusCode = 200 + for await (const chunk of route.page.render(input)) res.write(chunk) + res.end() + } catch (err) { + // A throw before the first byte (e.g. a duplicate key caught outside ) becomes a clean + // 500; once streaming has started the status is already sent, so just end. + if (!res.headersSent) { + res.statusCode = 500 + res.end('SSR error: ' + (err instanceof Error ? err.message : String(err))) + } else { + res.end() + } + } +} diff --git a/examples/marko/streaming/src/inorder-page.marko b/examples/marko/streaming/src/inorder-page.marko new file mode 100644 index 00000000..ca342cc9 --- /dev/null +++ b/examples/marko/streaming/src/inorder-page.marko @@ -0,0 +1,30 @@ +import { createStore } from '@tanstack/marko-store' + +export interface Input { + value: number +} + + + + Streaming (in-order) + +
+

Streaming a store into a late await (in-order)

+

The server computes per-request data inside a late await; the store is born from that value with <stream-store-provider>, renders server-side with no flash, and stays live after resume.

+ + + +

Resumed in browser: ${resumed}

+ + + setTimeout(() => r(input.value), 2000))> + ({ store: createStore({ n: slice }) })> + c.store) selector=((s: any) => s.n)/> +

Streamed value: ${n}

+ + +
+ +
+ + diff --git a/examples/marko/streaming/src/outoforder-page.marko b/examples/marko/streaming/src/outoforder-page.marko new file mode 100644 index 00000000..a288a5bd --- /dev/null +++ b/examples/marko/streaming/src/outoforder-page.marko @@ -0,0 +1,46 @@ +import { createStore } from '@tanstack/marko-store' + +export interface Input { + slow: number + fast: number +} + + + + Streaming (out-of-order) + +
+

Streaming out-of-order with try/placeholder

+

Each awaited block is born with its own store. Wrapping an await in <try> with a <@placeholder> lets the fast block paint before the slow one, while each store stays live. Click either inc button after the block lands to confirm the store is interactive post-resume.

+ + + +

Resumed in browser: ${resumed}

+ + + + setTimeout(() => r(input.slow), 2500))> + ({ store: createStore({ n: ss }) })> + c.store) selector=((s: any) => s.n)/> +

Slow: ${vs}

+ + +
+ + <@placeholder>

loading slow…

+
+ + + setTimeout(() => r(input.fast), 800))> + ({ store: createStore({ n: sf }) })> + c.store) selector=((s: any) => s.n)/> +

Fast: ${vf}

+ + +
+ + <@placeholder>

loading fast…

+
+
+ + diff --git a/examples/marko/streaming/tsconfig.json b/examples/marko/streaming/tsconfig.json new file mode 100644 index 00000000..91bb9b96 --- /dev/null +++ b/examples/marko/streaming/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.marko", + "../../../packages/marko-store/src/tags/*.d.marko" + ] +} diff --git a/knip.json b/knip.json index 7b3bcfa8..c3426e8e 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,19 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { + "packages/marko-store": { + "entry": [ + "src/index.ts", + "src/tags/**/*", + "e2e/server.mjs", + "e2e/src/index.ts", + "e2e/src/store.ts", + "e2e/src/context-store.ts", + "e2e/src/streaming-store.ts", + "e2e/src/helper-eo.ts" + ], + "ignoreDependencies": ["@marko/language-tools", "marko"] + }, "packages/vue-store": { "ignoreDependencies": ["vue2", "vue2.7"] } diff --git a/package.json b/package.json index b3e0f4b6..61cfa049 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "nx": "22.6.5", "premove": "^4.0.0", "prettier": "^3.8.2", + "prettier-plugin-marko": "^4.0.9", "prettier-plugin-svelte": "^3.5.1", "publint": "^0.3.18", "sherif": "^1.11.1", diff --git a/packages/marko-store/README.md b/packages/marko-store/README.md new file mode 100644 index 00000000..7263a585 --- /dev/null +++ b/packages/marko-store/README.md @@ -0,0 +1,567 @@ +# @tanstack/marko-store + +Marko 6 adapter for [TanStack Store](https://tanstack.com/store). It re-exports the +entire `@tanstack/store` core and adds five Marko tags so a component can read a +store, stay in sync as it changes, write to it, share stores with its children, and +build a store from data that streams in. + +## The five tags + +- `` — read a value out of a store and re-render when it changes. +- `` — read and write a single-value atom. +- `` — hand a group of stores down to children. +- `` — grab those stores from a child in order to write to them. +- `` — build a store from data that arrives inside a streamed + ``, then hand it down like a provider. + +`` and `` are a pair: `` shares the stores, +and `` (or a selector in `context` mode) reads them back. `` +does nothing on its own — with no provider above it, it throws (see Sharing stores). +`` is the streaming sibling of `` — the same bundle, +for data that only shows up inside an `` (see Streaming data into a store). + +## Words used in this README + +The rest of the doc leans on these, so it's worth pinning them down first. + +- **Store** — an object that holds some state and lets you read it, change it, and + listen for changes: `createStore({ count: 0, name: "Ada" })`. +- **Snapshot** (the store's *value*) — the whole state object right now, + `{ count: 0, name: "Ada" }`. You read it with `store.state`. +- **Slice** — the part of a store's snapshot you select to watch. It can be a single + field or a few related fields grouped together: `s => s.count`, or + `s => ({ id: s.id, name: s.name })`. A slice is a portion of *one store's* state — + not the same as a bundle member, which is a whole store inside a bundle (see Bundle). +- **Selector** — that picking function. `` re-renders only when the + slice the selector returns changes, not on every store change. +- **Atom** — a store for a *single value* (`createAtom(0)`) with a direct setter. + Reach for it when the state is just one thing. +- **Bundle** — one plain object that groups several stores under names, + `{ user, cart }`. It's what `` shares. A bundle is one object + holding many stores — not many providers (see "Sharing stores" below). + +## Installation + +```sh +npm install @tanstack/store @tanstack/marko-store +``` + +`marko` is a peer dependency (`^6`). + +## Store or atom — which one? + +Use a **Store** when your state has more than one field, or when you want to read +just a slice of it and re-render only when that slice changes. Use an **Atom** when +the state is a single value — a number, a flag, a string — that you read and set as +a whole. + +The same counter, written both ways: + +```marko +import { createStore, createAtom } from "@tanstack/store" + +// Atom: the state IS the number. Read and set it directly. +static const countAtom = createAtom(0) + countAtom)> + + + +// Store: the number is one field of an object. Pick it with a selector, +// change it with setState. +static const countStore = createStore({ count: 0, updatedAt: 0 }) + countStore) selector=(s => s.count)> + + +``` + +Rule of thumb: one value → atom; an object you slice → store. + +## Reading a store: `` + +### Why not just read the value directly? + +A store holds *live* state. Reading `store.state.count` once gives you the number at +that instant and nothing more — when the store changes later, your markup won't. +`` subscribes to the store for you and re-renders only when the slice +you selected changes. So `${store.state.count}` would show a stale `0` forever, while +`` keeps it correct. In short, `` is the piece that +makes your markup *reactive* to the store — a plain read is a one-off snapshot that +never updates. + +### Basic use + +```marko +static const store = createStore({ count: 0, name: "Ada" }) + store) selector=(s => s.count)> +

Count: ${count}

+
+``` + +`from` is a *function* that returns the store, never the store itself. That keeps the +store out of serialized component state, which is what lets the page resume after +server rendering. It must return the same store every call — don't create the store +inside it. + +(`static const store = ...`, as in the examples, runs once when the module loads — not +on every render — so the store lives at module level and never becomes part of +serializable component state. That's why it resumes cleanly and isn't "caught" by +serialization. The one catch is that a module-level store is shared across requests on +a server, which is why server rendering builds stores per request instead — see +Module-level vs per-request stores.) + +### Attributes + +| Attribute | Required | Description | +| ---------- | -------- | ------------------------------------------------------------------ | +| `from` | \* | Function returning the store/atom (`() => store`). | +| `context` | \* | Pick a store out of a provided bundle (see Sharing stores). Use instead of `from`. | +| `selector` | no | Maps the snapshot to the slice you want. Defaults to the whole snapshot. | +| `compare` | no | Decides what counts as "changed" for the slice. Defaults to `===`. | +| `key` | no | Which provided bundle to read, when using `context`. Defaults to the shared key. | + +\* Supply exactly one of `from` or `context`. Passing both throws. + +### The optional attributes, by example + +No `selector` — track the whole snapshot: + +```marko + store)> +
${JSON.stringify(whole)}
+
+``` + +`compare` — define what "changed" means. Here, ignore case so `"Ada"` → `"ada"` does +not re-render: + +```marko + store) + selector=(s => s.name) + compare=((a, b) => a.toLowerCase() === b.toLowerCase()) +> +

${name}

+
+``` + +`context` — read a store out of a bundle a `` shared, instead of from +a store you hold directly: + +```marko + c.user) selector=(s => s.name)> +

${name}

+
+``` + +Use `context` when the store comes from a provider above you; use `from` when you +already hold the store (imported or module-level). The two are exclusive — exactly one. + +`key` — when using `context`, picks which bundle to read if more than one provider is +in play; covered under Sharing stores. + +Swapping the `selector` always re-publishes the new slice; `compare` only suppresses +repeat updates coming from store changes, never a deliberate change of selector. + +## Writing an atom: `` + +Two-way binding for an atom. The bound value is writable: assigning it calls the +atom's setter. + +```marko +static const count = createAtom(0) + count)> + + +``` + +| Attribute | Required | Description | +| --------- | -------- | ------------------------------------ | +| `from` | yes | Function returning the atom (`() => a`). | + +`` is for atoms — it writes through the atom's `.set`. Hand it a `Store` +and it throws a clear error at render, because a `Store` has no such setter: read a +`Store` with `` and write it with `store.setState(...)`. + +> Several writes in one tick collapse into one update. For dependent updates, use the +> function form: `count.set(p => p + 1)`. + +## Sharing stores with children: `` + `` + +### What a bundle is, and how it differs from separate stores + +A bundle is one plain object that groups stores under names: `{ user, cart }`. +`` parks that one object where children can find it, and each child +picks the store it wants out of it. This is deliberately **one provider holding many +stores** — not one provider per store. (Two providers under the same name clash; see +Nesting.) + +### Is this just Marko's missing context tag? + +It fills a similar role, but it isn't a generic context tag — and Marko's own +mechanism is worth knowing. + +First, two ideas worth separating, since both involve "sharing." A **store** is the +live, changing state itself — you read it, write it, and subscribe to it, and it lasts +as long as you hold a reference to it. **Context** is only a *delivery* mechanism: a way +for a value to reach a deep child without threading it through every tag in between. On +its own, context says nothing about reactivity or how long something lives — it just +gets a value from an ancestor to its descendants. `` combines the two: +it delivers a *store* through a context-style channel, so children get both the reach +of context and the reactivity of a store. + +"Context" usually means sharing a value down the tree without threading it through +every tag in between — a theme, the current user, a request id. Marko v5 did have a +`` tag (in `@marko/tags`), but it's a class-component tag that passes plain +data, updates only when the provider re-renders (client-side), and predates Marko 6's +resumable runtime — so it's not a reactive store and not resume-safe. Marko 6 — the +tags API this adapter targets — has no `` tag at all. Either way, the built-in +way to share a value is `$global` (the same object older Marko calls `out.global`): a +plain bag of values attached to one render. Two things +matter about it. It is **not reactive** — writing to `$global` doesn't re-render +anything. And it exists on **both** the server and the client render, but a value you +set on the server only reaches the client if you name it in `$global.serializedGlobals` +(a list/map of keys Marko then serializes into the page — the classic example is a CSP +nonce). Plain reads of those serialized values are available as `out.global` / +`$global` on the client. + +`` is built on `$global` but is **store-specific**, not a general value +carrier. It groups stores as a bundle; because a live store can't be serialized it does +*not* put stores in `serializedGlobals` — instead it rebuilds the stores per request on +each side from data (the data rides the normal serialized input), so server and client +end up with matching stores. And since `$global` isn't reactive, the provider rings a +small internal signal when it mounts so selectors notice the bundle. So the rule of +thumb: to share a plain value, use `$global` / `serializedGlobals` directly; to share +*stores*, use ``. + +### Can providers be nested? + +Yes. Give each provider a distinct `key`, and a child reads whichever bundle it wants +by passing the matching key. Two providers sharing a key throw on purpose — that's +what stops two features from clobbering each other. (Verified by tests.) + +The two `key`s play different roles: the **provider's** `key` *names* the bundle (the +slot it parks under), while a **selector's** or **``'s** `key` *chooses* +which named bundle to read. It's the same name seen from the writing side and the +reading side, so set them to match. Omit `key` everywhere and they all use one shared +default name. + +```marko + ({ session }))> + ({ cart }))> + c.session) selector=(s => s.user)/> + c.cart) selector=(s => s.items)/> + + +``` + +### The usual (server-render-ready) shape + +Pass the per-request **data** and let the provider build the stores from it. Each +request gets its own fresh stores, and because only the data is serialized — the +stores are rebuilt from it on both the server and the client — the page resumes live +with nothing non-serializable crossing. A bundle can hold any number of stores; each +is read by its own selector and resumes on its own. + +```marko +import { createStore } from "@tanstack/store" + +export interface Input { + user: { name: string } + cart: { items: Array } +} + + ({ + user: createStore(input.user), + cart: createStore(input.cart), +}))> + + c.user) selector=(s => s.name)> +

${name}

+
+ + c.cart) selector=(s => s.items.length)> +

${itemCount} in cart

+
+ + + + + +
+``` + +`user` and `cart` live in one bundle, are each created from their own slice of the +per-request input, and resume independently on the client — incrementing the cart +doesn't touch `user`, and either store rehydrates on its own. + +`` returns a *getter* for the bundle, so write handlers call `ctx()` +and pick the member: `ctx().user.setState(...)`. It only works inside a +`` — there has to be a bundle for it to read — so with no provider +above it, it throws a clear error at render. (The selector's `context` mode is the +same; only `from` mode works without a provider.) + +So when *do* you write through `` versus directly? If you already hold a +store — imported or module-level — you write to it directly: `store.setState(...)`, +right in the handler. There's no write tag for that case and you don't need one +(`` only *reads*). `` exists for the one case where you +*don't* hold the store: a `` handed it down, so the child has no direct +reference, and `` is how it reaches back to the bundle to call +`setState`. (Atoms are the same idea: hold one directly and you use `` or +`atom.set(...)`.) + +### What about a browser-only app (no server rendering)? + +If your app runs only in the browser — a plain single-page app — there's no server +render to match, so you don't need per-request data. Create the stores once when the +module loads and put those same stores in the bundle: + +```marko +static const user = createStore({ name: "Ada" }) +static const cart = createStore({ items: [] }) + + ({ user, cart }))> + ... + +``` + +The per-request version above exists only because server rendering needs a fresh set +of stores for every request. A browser-only app has just one session, so module-level +stores are fine. + +### Module-level vs per-request stores + +A **module-level store** is created once, at the top of a module +(`static const store = createStore(...)`), and there is a single shared instance for +the whole process. In the browser that's exactly what you want, and here's why: each +visitor loads the page in their *own* browser, so each gets their own copy of the +module and their own store instance. Its state builds up over that one session, fully +isolated from every other user. It's what the selector and atom examples above use. + +A **per-request store** is created inside the provider's `value` from incoming data +(`createStore(input.data)`), so a fresh instance is built for each request, and again +on the client during resume. + +Why it matters: on a server a module-level store is shared by *every* request at once, +so one visitor's state would leak into another's. Server rendering therefore needs +per-request stores. A browser-only app doesn't have that problem, so either works. +Rule of thumb: server-rendered → build the stores per request from data; browser-only +→ module-level stores are fine. + +### `` attributes + +| Attribute | Required | Description | +| --------- | -------- | ----------------------------------------------------------------- | +| `value` | yes | Function returning the bundle object (`() => ({ a, b })`). | +| `key` | no | Name this bundle so a selector/context can target it. Defaults to a shared name. | + +## Streaming data into a store: `` + +### What "streaming" means here, and in-order vs out-of-order + +Server rendering normally sends the whole page at once. **Streaming** lets the server send the +page in pieces: it flushes the shell immediately, then sends each slow piece — a section waiting on +a database call, say — later, as its data becomes ready. In Marko you mark a slow piece with +``: the shell paints right away, and the awaited block streams in when its promise resolves. + +Two slow pieces can finish in either order. **In-order** streaming keeps them in document order — a +piece that finishes early still waits its turn, so the page fills top to bottom. A plain `` +is in-order. **Out-of-order** streaming lets a piece that finishes first paint first, even if it +sits lower in the page, with a placeholder holding its spot until then; you opt into it by wrapping +the `` in `` with a `<@placeholder>`. Out-of-order reaches first content sooner but +needs the placeholder slot. `` behaves identically either way. + +### Why a separate tag from `` + +`` builds its stores up front, in the shell, because their data is ready before the +page renders. Streamed data isn't ready then — it only exists once the `` resolves, which is +*after* the shell (and the provider) have already rendered. So the provider can't build a store +from streamed data: it runs too early. `` is the provider you put *inside* +the awaited block, where the data finally exists. It builds the store there — on the server as the +piece renders, and again in the browser as the piece resumes — so the store is **born with the +data** on both sides. The value paints on the server (no empty first frame, no flash) and the store +is live after resume, exactly like ``, just sourced from data that arrived late. + +Under the hood it is the same machinery as ``: the store is parked on `$global` +(never serialized), and only the plain awaited value crosses the wire, carried inside the block +because the `value` thunk names it. A live store is never serialized. This is also why you must not +hand the awaited data to a store you hold in an ordinary variable inside the block — see Gotchas. + +### When to use it, and when not to + +Use `` when a store's data arrives **inside a streamed ``** — a +per-request fetch you have deferred so the shell can paint first. It works whether that `` +is in-order or wrapped in `` for out-of-order. + +Do **not** use it for data that is ready at render — a normal per-request store built from `input`. +That is ``'s job; reach for it there. And it is not a general "lazy provider": its +whole reason to exist is the streamed-`` timing, so put it inside an ``. (Used outside +one it degrades to provider-like behavior and still works, but then you have just written a heavier +``.) + +Rule of thumb: data ready at render → ``; data that streams in inside an `` +→ ``. + +### Basic use + +```marko +import { createStore } from "@tanstack/store" + +export interface Input { + userId: string +} + + + ({ profile: createStore(profile) }))> + c.profile) selector=(s => s.name)> +

${name}

+
+
+
+``` + +`profile` is the awaited data. The `value` thunk closes over it and builds the bundle right there. +Because the thunk *names* `profile`, Marko ships that data with the block, so the same thunk +rebuilds the store in the browser when the block resumes. Children read it with `` +in `context` mode exactly as under ``, and write it through ``. The +`value` thunk and the bundle are identical to `` — a bundle can hold any number of +stores, each read by its own selector. The only shape difference is *where* the tag goes: inside the +``, so it can see the data. + +### Out-of-order, and catching errors: `` + +The helper is ordering-agnostic — it only builds the store inside the block. Ordering is decided by +the block around it. A plain `` is in-order. To go out-of-order, wrap the `` in +`` with a `<@placeholder>`; the block can then paint as soon as its data resolves, with the +placeholder holding its slot until then: + +```marko + + + ({ profile: createStore(profile) }))> + c.profile) selector=(s => s.name)> +

${name}

+
+
+
+ <@placeholder> +

Loading…

+ +
+``` + +`` is also Marko's error boundary for a streamed block. If anything inside the `` +throws — a rejected fetch, or the duplicate-key guard below — add a `<@catch>` and the block renders +the fallback instead of breaking the stream: + +```marko + + + ({ profile: createStore(profile) }))> + c.profile) selector=(s => s.name)> +

${name}

+
+
+
+ <@catch|err|> +

Couldn't load that section: ${err.message}

+ +
+``` + +This is Marko's `` / `<@catch>`, not a JavaScript `try`/`catch`. + +### Keys: name the box, and don't share it + +`key` works exactly like ``'s, and is **required** here (no default). It names the +box the bundle is parked in, and the matching `key` on a selector or `` chooses which +box to read. Give each streamed provider a distinct `key`. + +A `` and a `` — or two of either — on the **same key** is +always a mistake: a box holds one bundle, so the second would clobber the first. They detect each +other and **throw** a duplicate-key error rather than overwrite silently. Wrap the block in +`` / `<@catch>` if you want that surfaced as a fallback instead of a broken stream (see above). +`key` is required, with no shared default, precisely so a streamed provider cannot quietly collide +with a top-level provider's default name. + +### Gotchas + +- **Do not hold the store in a plain variable inside the block.** Build it through the `value` thunk + (which parks it on `$global`); never as `` inside the ``. A + `` becomes part of the block's serialized state, a live store cannot be serialized, and the + server render throws "Unable to serialize". The thunk keeps the store off that path. This is the + one real trap, and the reason the tag exists instead of a one-liner. +- **A selector *above* the block** cannot have the data yet — the block has not rendered. It shows + its default (empty / `undefined`, since the selector is null-tolerant) and then updates the instant + the block lands and the store is parked. A selector *inside* the block shows the value immediately, + with no flash. Put readers that must be correct on first paint inside the block. +- **`` above the block throws** at render, because the box is still empty at that + point — you cannot grab a streamed store from above where it is created. Read or write the streamed + store from inside the block. +- **Browser-only apps** (no server render) build the store once: the tag guards against the build + running twice on a pure client render. You generally do not need streaming in a browser-only app, + but it is safe if it appears. + +### `` attributes + +| Attribute | Required | Description | +| --------- | -------- | --------------------------------------------------------------------------- | +| `value` | yes | Function returning the bundle object (`() => ({ a, b })`), built from the awaited data. Same as ``. | +| `key` | yes | Names this bundle's box so a selector / `` can target it. Required, and must be distinct per provider — there is no shared default. | + +Reading and writing the streamed stores is unchanged: `` in `context` mode reads a +member, `` reaches the bundle to write, and typing the bundle works the same way +(see Typing the bundle). + +## Typing the bundle + +This section is about TypeScript and editor autocomplete only — it changes nothing at +runtime. + +When you read a member with `context=(c => c.user)`, TypeScript can't tell what `c` +is. The bundle's shape doesn't travel across the tag boundary in the current Marko +type tooling, so `c` comes through as `unknown` — TypeScript's "I have no idea what +this is" type. On an `unknown` value, `c.user` has no type, you get no autocomplete, +and if you try to use it as a specific shape TypeScript reports an error. + +The fix is to tell TypeScript the shape yourself, in two places: + +```marko +// 1) annotate the picker's parameter + c.user) + selector=(s => s.name) +/> + +// 2) assert the getter when writing +(ctx() as { user: typeof user; cart: typeof cart }).user.setState(...) +``` + +`typeof user` just reuses the store's own type, so you don't retype its shape. Once +annotated, `c.user` and the write are fully typed with working autocomplete. (We +looked hard for a way to make this automatic; in the current Marko toolchain it isn't, +so the annotation is the supported way. It's a typing-ergonomics gap, not a +functionality one.) + +## SSR vs client-side: things to know + +- **Server-rendered page.** Every tag paints correctly on the first render, and the + page picks up live updates after it hydrates. Nothing special to do. +- **Browser-only first paint.** When a page is mounted purely in the browser (not + server-rendered) and a selector reads from a context bundle, that selector can show + an empty value for the very first frame and then immediately correct itself. The + provider fills the bundle a beat before the selector reads it, and a built-in + recovery step catches up right away. Server-rendered pages don't show this. +- **Streaming with ``.** When a store's data arrives inside a streamed ``, + build the store right inside the block with `` (see "Streaming + data into a store"). It's born with the data on both the server and the client, so the + value paints during streaming with no flash and resumes live. The one rule: build the + store through the tag's `value` thunk — never hold it in a plain variable inside the + block, since a live store can't be serialized with the block's state. + +## License + +MIT \ No newline at end of file diff --git a/packages/marko-store/e2e/README.md b/packages/marko-store/e2e/README.md new file mode 100644 index 00000000..eea11381 --- /dev/null +++ b/packages/marko-store/e2e/README.md @@ -0,0 +1,50 @@ +# marko-store e2e (resume liveness) + +A real-browser proof that an SSR'd page using `` and `` resumes and +stays reactive. jsdom can render and serialize but cannot faithfully reproduce Marko 6's client +resume, so this round-trip lives here (Playwright + real Chromium) rather than in the vitest +suite. + +## What it proves + +`store-resume-liveness.spec.ts` loads the SSR'd page and checks, in order: + +1. **The page resumed** -- a store-independent `data-testid="resumed"` marker flips from `no` + (server) to `yes` (client `onMount`). This is the precondition; if it stays `no`, the page + never resumed and any further result is inconclusive. +2. **Seeded values rendered** -- `selector-count` is `5` and `atom-value` is `7`, from the + module-singleton `createStore({ count: 5 })` / `createAtom(7)`. +3. **Selector subscription is live** -- clicking a button that calls `counterStore.setState` + from outside any tag moves `selector-count` to `6`. +4. **Atom write-back is live** -- clicking the atom button (`value++`) flows through the tag's + `valueChange` into `countAtom.set` and moves `atom-value` to `8`. +5. **No serialization crash** -- no `pageerror` / `console.error` during render or resume. + +A `resumed=yes` result with a stale value would mean a tag is inert after resume (the failure +this is designed to catch), distinct from a `resumed=no` setup failure. + +## Architecture + +- `server.mjs` -- Vite (middleware mode) + `@marko/vite`, on port 5188. It `ssrLoadModule`s the + JS entry `src/index.js` (not the `.marko` directly); only the JS-entry path makes `@marko/vite` + inject the client `