@connextar/house 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -28
- package/dist/changelog/changelog.d.ts +60 -0
- package/dist/changelog/changelog.d.ts.map +1 -0
- package/dist/changelog/changelog.js +61 -0
- package/dist/changelog/changelog.js.map +1 -0
- package/dist/changelog/index.d.ts +2 -0
- package/dist/changelog/index.d.ts.map +1 -0
- package/dist/changelog/index.js +2 -0
- package/dist/changelog/index.js.map +1 -0
- package/dist/editor/block-handle.d.ts +15 -0
- package/dist/editor/block-handle.d.ts.map +1 -0
- package/dist/editor/block-handle.js +106 -0
- package/dist/editor/block-handle.js.map +1 -0
- package/dist/editor/blocks.d.ts +35 -0
- package/dist/editor/blocks.d.ts.map +1 -0
- package/dist/editor/blocks.js +114 -0
- package/dist/editor/blocks.js.map +1 -0
- package/dist/editor/bubble-toolbar.d.ts +11 -0
- package/dist/editor/bubble-toolbar.d.ts.map +1 -0
- package/dist/editor/bubble-toolbar.js +87 -0
- package/dist/editor/bubble-toolbar.js.map +1 -0
- package/dist/editor/content-index.d.ts +2 -0
- package/dist/editor/content-index.d.ts.map +1 -0
- package/dist/editor/content-index.js +2 -0
- package/dist/editor/content-index.js.map +1 -0
- package/dist/editor/content.d.ts +30 -0
- package/dist/editor/content.d.ts.map +1 -0
- package/dist/editor/content.js +256 -0
- package/dist/editor/content.js.map +1 -0
- package/dist/editor/editor.d.ts +52 -0
- package/dist/editor/editor.d.ts.map +1 -0
- package/dist/editor/editor.js +160 -0
- package/dist/editor/editor.js.map +1 -0
- package/dist/editor/emoji-index.d.ts +2 -0
- package/dist/editor/emoji-index.d.ts.map +1 -0
- package/dist/editor/emoji-index.js +2 -0
- package/dist/editor/emoji-index.js.map +1 -0
- package/dist/editor/emoji-picker.d.ts +16 -0
- package/dist/editor/emoji-picker.d.ts.map +1 -0
- package/dist/editor/emoji-picker.js +37 -0
- package/dist/editor/emoji-picker.js.map +1 -0
- package/dist/editor/emoji.d.ts +18 -0
- package/dist/editor/emoji.d.ts.map +1 -0
- package/dist/editor/emoji.js +418 -0
- package/dist/editor/emoji.js.map +1 -0
- package/dist/editor/index.d.ts +5 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/editor/suggestion-popup.d.ts +17 -0
- package/dist/editor/suggestion-popup.d.ts.map +1 -0
- package/dist/editor/suggestion-popup.js +57 -0
- package/dist/editor/suggestion-popup.js.map +1 -0
- package/dist/editor/suggestions.d.ts +93 -0
- package/dist/editor/suggestions.d.ts.map +1 -0
- package/dist/editor/suggestions.js +179 -0
- package/dist/editor/suggestions.js.map +1 -0
- package/dist/editor/ui.d.ts +54 -0
- package/dist/editor/ui.d.ts.map +1 -0
- package/dist/editor/ui.js +58 -0
- package/dist/editor/ui.js.map +1 -0
- package/dist/house.css +333 -0
- package/dist/internal/cx.d.ts +3 -0
- package/dist/internal/cx.d.ts.map +1 -0
- package/dist/internal/cx.js +5 -0
- package/dist/internal/cx.js.map +1 -0
- package/dist/internal/next-link.d.ts +23 -0
- package/dist/internal/next-link.d.ts.map +1 -0
- package/dist/internal/next-link.js +3 -0
- package/dist/internal/next-link.js.map +1 -0
- package/dist/markdown/guide.d.ts +98 -0
- package/dist/markdown/guide.d.ts.map +1 -0
- package/dist/markdown/guide.js +328 -0
- package/dist/markdown/guide.js.map +1 -0
- package/dist/markdown/index.d.ts +4 -0
- package/dist/markdown/index.d.ts.map +1 -0
- package/dist/markdown/index.js +4 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/table-view.d.ts +28 -0
- package/dist/markdown/table-view.d.ts.map +1 -0
- package/dist/markdown/table-view.js +25 -0
- package/dist/markdown/table-view.js.map +1 -0
- package/dist/markdown/table.d.ts +35 -0
- package/dist/markdown/table.d.ts.map +1 -0
- package/dist/markdown/table.js +79 -0
- package/dist/markdown/table.js.map +1 -0
- package/dist/release/changelog.d.ts +72 -0
- package/dist/release/changelog.d.ts.map +1 -0
- package/dist/release/changelog.js +92 -0
- package/dist/release/changelog.js.map +1 -0
- package/dist/release/index.d.ts +3 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +3 -0
- package/dist/release/index.js.map +1 -0
- package/dist/release/version.d.ts +33 -0
- package/dist/release/version.d.ts.map +1 -0
- package/dist/release/version.js +57 -0
- package/dist/release/version.js.map +1 -0
- package/dist/trail/index.d.ts +3 -0
- package/dist/trail/index.d.ts.map +1 -0
- package/dist/trail/index.js +3 -0
- package/dist/trail/index.js.map +1 -0
- package/dist/trail/rules.d.ts +44 -0
- package/dist/trail/rules.d.ts.map +1 -0
- package/dist/trail/rules.js +74 -0
- package/dist/trail/rules.js.map +1 -0
- package/dist/trail/trail.d.ts +49 -0
- package/dist/trail/trail.d.ts.map +1 -0
- package/dist/trail/trail.js +188 -0
- package/dist/trail/trail.js.map +1 -0
- package/dist/ux/errors.d.ts +26 -0
- package/dist/ux/errors.d.ts.map +1 -0
- package/dist/ux/errors.js +33 -0
- package/dist/ux/errors.js.map +1 -0
- package/dist/ux/feedback.d.ts +23 -0
- package/dist/ux/feedback.d.ts.map +1 -0
- package/dist/ux/feedback.js +22 -0
- package/dist/ux/feedback.js.map +1 -0
- package/dist/ux/index.d.ts +20 -0
- package/dist/ux/index.d.ts.map +1 -0
- package/dist/ux/index.js +20 -0
- package/dist/ux/index.js.map +1 -0
- package/dist/ux/navigation-index.d.ts +10 -0
- package/dist/ux/navigation-index.d.ts.map +1 -0
- package/dist/ux/navigation-index.js +10 -0
- package/dist/ux/navigation-index.js.map +1 -0
- package/dist/ux/navigation-progress.d.ts +53 -0
- package/dist/ux/navigation-progress.d.ts.map +1 -0
- package/dist/ux/navigation-progress.js +245 -0
- package/dist/ux/navigation-progress.js.map +1 -0
- package/dist/ux/optimistic.d.ts +139 -0
- package/dist/ux/optimistic.d.ts.map +1 -0
- package/dist/ux/optimistic.js +242 -0
- package/dist/ux/optimistic.js.map +1 -0
- package/dist/ux/use-action.d.ts +60 -0
- package/dist/ux/use-action.d.ts.map +1 -0
- package/dist/ux/use-action.js +100 -0
- package/dist/ux/use-action.js.map +1 -0
- package/dist/ux/use-optimistic-list.d.ts +69 -0
- package/dist/ux/use-optimistic-list.d.ts.map +1 -0
- package/dist/ux/use-optimistic-list.js +115 -0
- package/dist/ux/use-optimistic-list.js.map +1 -0
- package/dist/ux/use-optimistic-value.d.ts +40 -0
- package/dist/ux/use-optimistic-value.d.ts.map +1 -0
- package/dist/ux/use-optimistic-value.js +68 -0
- package/dist/ux/use-optimistic-value.js.map +1 -0
- package/docs/ReleaseProcess.md +315 -0
- package/package.json +83 -11
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { useActionReporter } from "./feedback.js";
|
|
5
|
+
import { applyPatches, nextPatchId, reconcile, settlePatch, supersede, tempKey, } from "./optimistic.js";
|
|
6
|
+
const NO_PATCHES = [];
|
|
7
|
+
/**
|
|
8
|
+
* A list the screen can change before the server has agreed.
|
|
9
|
+
*
|
|
10
|
+
* `base` is whatever the server last said — a server component's prop, or
|
|
11
|
+
* state filled by a fetch. Never write to it: the hook keeps its changes
|
|
12
|
+
* beside it and folds them in on render, which is what lets fresh rows take
|
|
13
|
+
* over cleanly whenever they turn up. See `optimistic.ts` for the rules.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const list = useOptimisticList(tasks, { key: (t) => t.id, onSettled: router.refresh });
|
|
17
|
+
*
|
|
18
|
+
* <Checkbox
|
|
19
|
+
* checked={task.done}
|
|
20
|
+
* disabled={list.isPending(task.id)}
|
|
21
|
+
* onCheckedChange={(done) =>
|
|
22
|
+
* list.update(task.id, { done }, () => commitApi(`/api/tasks/${task.id}`, { method: "PATCH", … }))
|
|
23
|
+
* }
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function useOptimisticList(base, options) {
|
|
28
|
+
const report = useActionReporter();
|
|
29
|
+
const [patches, setPatches] = React.useState(NO_PATCHES);
|
|
30
|
+
// Everything the asynchronous half needs, read at the time it runs.
|
|
31
|
+
const latest = React.useRef({ base, options, report });
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
latest.current = { base, options, report };
|
|
34
|
+
});
|
|
35
|
+
// What is still worth applying, worked out fresh on every render rather than
|
|
36
|
+
// stored. A settled patch knows what the rows said when it landed, so
|
|
37
|
+
// `reconcile` can tell "nothing has refetched yet" from "new rows are here"
|
|
38
|
+
// without this hook having to watch the array — and it must not watch it:
|
|
39
|
+
// `useOptimisticList(rows.filter(…), …)` hands it a different array every
|
|
40
|
+
// render, and any state keyed on that identity renders forever.
|
|
41
|
+
const live = reconcile(patches, base, options.key);
|
|
42
|
+
const write = React.useCallback(async (batch, commit, per) => {
|
|
43
|
+
// Queued at the end, where it wins on render, and *without* clearing
|
|
44
|
+
// what is already held for these rows. An earlier change that succeeded
|
|
45
|
+
// is still true: rolling this one back must not take it down too.
|
|
46
|
+
//
|
|
47
|
+
// Reconciling here as well is the only pruning the stored queue gets.
|
|
48
|
+
// Render works out what to apply without touching state — it has to, or
|
|
49
|
+
// a caller passing `rows.filter(…)` would render forever — so this is
|
|
50
|
+
// where changes the rows have caught up with are actually let go of.
|
|
51
|
+
setPatches((previous) => [...reconcile(previous, latest.current.base, latest.current.options.key), ...batch]);
|
|
52
|
+
const ids = new Set(batch.map((patch) => patch.id));
|
|
53
|
+
const { options: opts, report: tell } = latest.current;
|
|
54
|
+
try {
|
|
55
|
+
const result = (await commit());
|
|
56
|
+
// A single write may be answered with the row as the server has it; a
|
|
57
|
+
// batch has no one row to be answered with.
|
|
58
|
+
const answer = batch.length === 1 ? (result ?? undefined) : undefined;
|
|
59
|
+
setPatches((previous) => {
|
|
60
|
+
const { base: rows, options: current } = latest.current;
|
|
61
|
+
const landed = previous
|
|
62
|
+
.filter((held) => ids.has(held.id))
|
|
63
|
+
.map((held) => settlePatch(held, answer, rows, current.key));
|
|
64
|
+
// Now that this one has landed it supersedes what was already held
|
|
65
|
+
// for the same rows. Writes still in flight are left: each has a
|
|
66
|
+
// request of its own that can still come back refused.
|
|
67
|
+
const keys = new Set(landed.map((held) => held.key));
|
|
68
|
+
const kept = supersede(previous.filter((held) => !ids.has(held.id)), keys);
|
|
69
|
+
return [...kept, ...landed];
|
|
70
|
+
});
|
|
71
|
+
if (per?.success)
|
|
72
|
+
tell({ tone: "success", title: per.success });
|
|
73
|
+
if (!per?.silentSettle)
|
|
74
|
+
opts.onSettled?.();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (thrown) {
|
|
78
|
+
// Roll back: the rows go back to whatever the server last said, which
|
|
79
|
+
// `base` still holds untouched.
|
|
80
|
+
setPatches((previous) => previous.filter((held) => !ids.has(held.id)));
|
|
81
|
+
tell({
|
|
82
|
+
tone: "error",
|
|
83
|
+
title: per?.error ?? opts.error ?? "That didn't save",
|
|
84
|
+
description: errorMessage(thrown),
|
|
85
|
+
});
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
const insert = React.useCallback((row, commit, per) => {
|
|
90
|
+
const key = latest.current.options.key(row);
|
|
91
|
+
return write([{ id: nextPatchId(), kind: "insert", key, row, at: "end", settled: false }], commit, per);
|
|
92
|
+
}, [write]);
|
|
93
|
+
const update = React.useCallback((key, fields, commit, per) => write([{ id: nextPatchId(), kind: "update", key, fields, settled: false }], commit, per), [write]);
|
|
94
|
+
const remove = React.useCallback((key, commit, per) => write([{ id: nextPatchId(), kind: "remove", key, settled: false }], commit, per), [write]);
|
|
95
|
+
const move = React.useCallback((key, before, commit, per) => write([{ id: nextPatchId(), kind: "move", key, before, settled: false }], commit, per), [write]);
|
|
96
|
+
const bulk = React.useCallback((changes, commit, per) => write(changes.map(({ key, fields }) => ({
|
|
97
|
+
id: nextPatchId(),
|
|
98
|
+
kind: "update",
|
|
99
|
+
key,
|
|
100
|
+
fields,
|
|
101
|
+
settled: false,
|
|
102
|
+
})), commit, per), [write]);
|
|
103
|
+
const items = applyPatches(base, live, options.key);
|
|
104
|
+
const busy = React.useMemo(() => {
|
|
105
|
+
const keys = new Set();
|
|
106
|
+
for (const patch of live)
|
|
107
|
+
if (!patch.settled)
|
|
108
|
+
keys.add(patch.key);
|
|
109
|
+
return keys;
|
|
110
|
+
}, [live]);
|
|
111
|
+
const isPending = React.useCallback((key) => busy.has(key), [busy]);
|
|
112
|
+
return { items, isPending, pending: busy.size > 0, insert, update, remove, move, bulk };
|
|
113
|
+
}
|
|
114
|
+
export { tempKey };
|
|
115
|
+
//# sourceMappingURL=use-optimistic-list.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-optimistic-list.js","sourceRoot":"","sources":["../../src/ux/use-optimistic-list.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface OptimisticValueOptions {
|
|
2
|
+
/** Heading for the toast when the write fails. */
|
|
3
|
+
error?: string;
|
|
4
|
+
/** Run after the write lands — usually `router.refresh()`. */
|
|
5
|
+
onSettled?: () => void;
|
|
6
|
+
}
|
|
7
|
+
export interface OptimisticValue<T> {
|
|
8
|
+
/** What to render: the change if one is held, otherwise the server's value. */
|
|
9
|
+
value: T;
|
|
10
|
+
/** A write is in flight. */
|
|
11
|
+
pending: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Show `next` now, then commit it. The commit may resolve with the value as
|
|
14
|
+
* the server has it — and must resolve with **that value's own shape** or
|
|
15
|
+
* nothing at all. A write route often answers with some other record
|
|
16
|
+
* entirely, which would replace the value with something that is not one:
|
|
17
|
+
* `async () => { await save(); }` is the right wrapper in that case.
|
|
18
|
+
*/
|
|
19
|
+
set: (next: T, commit: () => Promise<T | void>, options?: {
|
|
20
|
+
success?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}) => Promise<boolean>;
|
|
23
|
+
/** The same for an object value: lay these fields over it. */
|
|
24
|
+
merge: (fields: Partial<T>, commit: () => Promise<T | void>, options?: {
|
|
25
|
+
success?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
}) => Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One value the screen can change before the server has agreed: a switch, a
|
|
31
|
+
* status, a counter, a settings record.
|
|
32
|
+
*
|
|
33
|
+
* Same rules as `useOptimisticList` and for the same reasons — the change is
|
|
34
|
+
* held beside the server's value rather than replacing it, so a failure rolls
|
|
35
|
+
* back to something real and a refresh always wins. A switch is the case where
|
|
36
|
+
* getting this wrong is most obvious: flipping back a second after it was
|
|
37
|
+
* flipped, with no explanation, is worse than not moving at all.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useOptimisticValue<T>(base: T, options?: OptimisticValueOptions): OptimisticValue<T>;
|
|
40
|
+
//# sourceMappingURL=use-optimistic-value.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-optimistic-value.d.ts","sourceRoot":"","sources":["../../src/ux/use-optimistic-value.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,sBAAsB;IACrC,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,+EAA+E;IAC/E,KAAK,EAAE,CAAC,CAAC;IACT,4BAA4B;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;OAMG;IACH,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACpH,8DAA8D;IAC9D,KAAK,EAAE,CACL,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAC/B,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KAC3C,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,GAAE,sBAA2B,GAAG,eAAe,CAAC,CAAC,CAAC,CA8DvG"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { useActionReporter } from "./feedback.js";
|
|
5
|
+
import { sameRow } from "./optimistic.js";
|
|
6
|
+
/**
|
|
7
|
+
* One value the screen can change before the server has agreed: a switch, a
|
|
8
|
+
* status, a counter, a settings record.
|
|
9
|
+
*
|
|
10
|
+
* Same rules as `useOptimisticList` and for the same reasons — the change is
|
|
11
|
+
* held beside the server's value rather than replacing it, so a failure rolls
|
|
12
|
+
* back to something real and a refresh always wins. A switch is the case where
|
|
13
|
+
* getting this wrong is most obvious: flipping back a second after it was
|
|
14
|
+
* flipped, with no explanation, is worse than not moving at all.
|
|
15
|
+
*/
|
|
16
|
+
export function useOptimisticValue(base, options = {}) {
|
|
17
|
+
const report = useActionReporter();
|
|
18
|
+
// `witness` is what the server's value was when the write landed. A settled
|
|
19
|
+
// hold stands until that changes, which is how "nothing has refreshed yet"
|
|
20
|
+
// is told apart from "the server now says something else". Worked out on
|
|
21
|
+
// each render rather than stored, so an object rebuilt by the parent on
|
|
22
|
+
// every render cannot start a render loop.
|
|
23
|
+
const [held, setHeld] = React.useState(null);
|
|
24
|
+
const latest = React.useRef({ options, report });
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
latest.current = { options, report };
|
|
27
|
+
});
|
|
28
|
+
// Nothing is stored when this flips: the next `set` replaces the hold
|
|
29
|
+
// anyway, and clearing it from an effect would only add a render.
|
|
30
|
+
const overtaken = held !== null && held.settled && !sameRow(held.witness, base);
|
|
31
|
+
const value = held && !overtaken ? held.value : base;
|
|
32
|
+
const valueRef = React.useRef(value);
|
|
33
|
+
const baseRef = React.useRef(base);
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
valueRef.current = value;
|
|
36
|
+
baseRef.current = base;
|
|
37
|
+
});
|
|
38
|
+
const set = React.useCallback(async (next, commit, per) => {
|
|
39
|
+
setHeld({ value: next, settled: false });
|
|
40
|
+
const { options: opts, report: tell } = latest.current;
|
|
41
|
+
try {
|
|
42
|
+
const result = (await commit());
|
|
43
|
+
// The server's own value where it sent one, so a clamped or normalised
|
|
44
|
+
// answer shows now rather than after the next refresh.
|
|
45
|
+
setHeld({
|
|
46
|
+
value: result === undefined || result === null ? next : result,
|
|
47
|
+
settled: true,
|
|
48
|
+
witness: baseRef.current,
|
|
49
|
+
});
|
|
50
|
+
if (per?.success)
|
|
51
|
+
tell({ tone: "success", title: per.success });
|
|
52
|
+
opts.onSettled?.();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch (thrown) {
|
|
56
|
+
setHeld(null);
|
|
57
|
+
tell({
|
|
58
|
+
tone: "error",
|
|
59
|
+
title: per?.error ?? opts.error ?? "That didn't save",
|
|
60
|
+
description: errorMessage(thrown),
|
|
61
|
+
});
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
const merge = React.useCallback((fields, commit, per) => set({ ...valueRef.current, ...fields }, commit, per), [set]);
|
|
66
|
+
return { value, pending: held !== null && !held.settled, set, merge };
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=use-optimistic-value.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-optimistic-value.js","sourceRoot":"","sources":["../../src/ux/use-optimistic-value.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Release Process
|
|
2
|
+
|
|
3
|
+
> How every Connextar app is versioned, how to decide whether a change is a **major**, **minor** or **patch** release, and the runbook for cutting one. Shared by the cohort and shipped inside `@connextar/house`; each app keeps a short `docs/operations/ReleaseProcess.md` that says only what is its own.
|
|
4
|
+
|
|
5
|
+
_First written for AltEd on 2026-09-12 and generalised for the cohort in `@connextar/house` 0.2.0._
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0. What each app's own document says
|
|
10
|
+
|
|
11
|
+
This document is the process. An app's `docs/operations/ReleaseProcess.md` links here and adds four things nobody else can write:
|
|
12
|
+
|
|
13
|
+
1. **Its public API** — the people, links, data and systems outside the repository that a change can break (§2).
|
|
14
|
+
2. **The trigger documents** — the status documents whose change means a release is due (§1).
|
|
15
|
+
3. **Where the changelog is shown** — the route, and the production address to check after a deploy.
|
|
16
|
+
4. **Its history note** — when it adopted this process, whether earlier releases were reconstructed, and the first tagged version (§7).
|
|
17
|
+
|
|
18
|
+
Worked examples from the app's own history belong there too; they teach the rules better than generic ones.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. What a release is
|
|
23
|
+
|
|
24
|
+
A **deploy** puts code on the servers; a **release** is the numbered, dated, announced unit people can point to — "that came in 0.14.0". A deploy does not need a release, and a release deploys nothing by itself.
|
|
25
|
+
|
|
26
|
+
A release is four records that must agree:
|
|
27
|
+
|
|
28
|
+
| Where | What it holds | Kept honest by |
|
|
29
|
+
| ----------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
30
|
+
| `version` in `package.json` and `package-lock.json` | The current version | The release guard test (§4) |
|
|
31
|
+
| `CHANGELOG` in `lib/changelog.ts`, newest first | What changed, written for the people who use the app; shown in the app | The release guard test — matches `package.json`, one step at a time, dates in order |
|
|
32
|
+
| An annotated git tag `vX.Y.Z` on the release commit | Where the release sits in history | Runbook step 8 |
|
|
33
|
+
| The _Last updated_ line of the status documents, and the work log | Which work the release shipped | Runbook step 6 |
|
|
34
|
+
|
|
35
|
+
### When to cut one
|
|
36
|
+
|
|
37
|
+
**Cut a release for every change set that touches the app's trigger documents** — the status documents where the delivery workflow records shipped work (usually `docs/ImplementationStatus.md`, and a marketing status document where there is one). Touching them is the signal. That covers feature work, security audit rounds, documentation pruning rounds, marketing-only work and fixes.
|
|
38
|
+
|
|
39
|
+
- **One change set, one release.** Don't hold work back to share a number; numbers cost nothing, and a version that means "everything since last week" means nothing.
|
|
40
|
+
- A change set that touches neither document (a local tooling tweak, say) rides along in the next release. If it changes what anyone experiences, it should have touched the documents in the first place.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Choosing the version
|
|
45
|
+
|
|
46
|
+
Versions follow [Semantic Versioning 2.0.0](https://semver.org): `MAJOR.MINOR.PATCH`, with no pre-release or build suffix. Tags are `v` plus the version (`v0.16.2`); the `version` field and the changelog carry the bare number (`0.16.2`).
|
|
47
|
+
|
|
48
|
+
### The public API
|
|
49
|
+
|
|
50
|
+
Semver is defined by what a change breaks, so first be clear who can be broken. For an app that is everything people and systems outside the repository rely on. The app's own document lists them; the categories are always these:
|
|
51
|
+
|
|
52
|
+
- **People** — the features they use, what their plan includes, and behaviour they have learnt.
|
|
53
|
+
- **Links people keep** — shared and tokenised links, calendar or data feeds, public marketing and guide URLs, bookmarks into the app.
|
|
54
|
+
- **Data people own** — their records, uploads and exports.
|
|
55
|
+
- **Systems** — webhook contracts, payment products and prices, scheduled endpoints, public APIs other services call.
|
|
56
|
+
|
|
57
|
+
Internal code — modules, API routes only the app's own UI calls, the database schema — is not the public API. Changing it is not breaking unless someone on the list feels it.
|
|
58
|
+
|
|
59
|
+
### MAJOR — someone has to change what they do
|
|
60
|
+
|
|
61
|
+
- A feature is removed or withdrawn.
|
|
62
|
+
- A plan includes less for people already on it, or prices rise for existing subscribers.
|
|
63
|
+
- A link people keep stops working without a redirect.
|
|
64
|
+
- Existing data is lost, hidden for good, or needs someone to act before it can be used again.
|
|
65
|
+
- A system contract changes incompatibly.
|
|
66
|
+
- Everyone must sign in again, or re-register passkeys.
|
|
67
|
+
|
|
68
|
+
### MINOR — something new, nothing broken
|
|
69
|
+
|
|
70
|
+
- A new feature, screen or capability.
|
|
71
|
+
- More people may do something they couldn't.
|
|
72
|
+
- A plan includes more.
|
|
73
|
+
- A new public route: a free tool, a landing page, a guide for a new feature.
|
|
74
|
+
- A new option on an existing feature that changes nothing for anyone who ignores it.
|
|
75
|
+
|
|
76
|
+
### PATCH — the same product, working better
|
|
77
|
+
|
|
78
|
+
- Bug fixes, including security fixes that stop something nobody was meant to be able to do.
|
|
79
|
+
- Copy, guides, marketing layout and presentation.
|
|
80
|
+
- Performance, accessibility and reliability improvements with no new capability.
|
|
81
|
+
- Refactors, tests, tooling, dependency updates nobody notices.
|
|
82
|
+
- Documentation-only work.
|
|
83
|
+
|
|
84
|
+
### Deciding a change set that mixes classes
|
|
85
|
+
|
|
86
|
+
1. **List the changes** from the work-log entry and the diff since the last release (runbook step 1).
|
|
87
|
+
2. **Classify each one** using the lists above.
|
|
88
|
+
3. **The release takes the highest class present.** One new feature and six fixes is a minor release.
|
|
89
|
+
4. **When unsure between two classes, choose the higher one** and say why in the work log.
|
|
90
|
+
|
|
91
|
+
| Situation | Class |
|
|
92
|
+
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
93
|
+
| A security fix closes a gap nobody was meant to use, even if a few accounts lose access they shouldn't have had | PATCH — describe the rule now enforced, never the exploit |
|
|
94
|
+
| A security fix removes something people legitimately relied on | MAJOR — an owner decision |
|
|
95
|
+
| A limit shows less but deletes nothing | MAJOR if it reduces what existing accounts see; MINOR if it only applies to new ones |
|
|
96
|
+
| An additive database migration | Nothing by itself — classify the feature it serves |
|
|
97
|
+
| A migration that needs someone to act, or loses data | MAJOR |
|
|
98
|
+
| A dependency upgrade | PATCH, unless people notice — then classify by what they notice |
|
|
99
|
+
| A page or guide renamed with a permanent redirect | PATCH |
|
|
100
|
+
|
|
101
|
+
### While the version is 0.x — early access
|
|
102
|
+
|
|
103
|
+
Semver treats `0.x` as a period when anything may change. Classify every change the same way, with one difference in how the number moves:
|
|
104
|
+
|
|
105
|
+
| Class of change | Before 1.0.0 | From 1.0.0 |
|
|
106
|
+
| --------------- | ------------------------------------------------------------------------------------------ | ---------- |
|
|
107
|
+
| MAJOR | Bump **MINOR**, and the release must carry a `changed` entry saying what people need to do | Bump MAJOR |
|
|
108
|
+
| MINOR | Bump MINOR | Bump MINOR |
|
|
109
|
+
| PATCH | Bump PATCH | Bump PATCH |
|
|
110
|
+
|
|
111
|
+
`nextVersion(current, kind)` from `@connextar/house/release` applies exactly this rule.
|
|
112
|
+
|
|
113
|
+
- **`1.0.0` is the owner's decision** — the moment the app leaves early access — and never an agent's.
|
|
114
|
+
- **From `1.0.0`, an agent does not cut a major release without the owner's explicit approval** in the conversation or the task prompt.
|
|
115
|
+
- Before 1.0.0, a MAJOR-class change is usually an owner decision anyway. Confirm that decision is recorded — in the prompt or the status document — before shipping it.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 3. Writing the release notes
|
|
120
|
+
|
|
121
|
+
The release is added to the top of `CHANGELOG` in `lib/changelog.ts`, typed as `ChangelogRelease` from `@connextar/house/release`:
|
|
122
|
+
|
|
123
|
+
| Field | Rule |
|
|
124
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
125
|
+
| `version` | The new version, no `v` |
|
|
126
|
+
| `date` | The UK calendar date the release is cut, `YYYY-MM-DD` |
|
|
127
|
+
| `title` | The headline in a few words — what someone would tell a friend |
|
|
128
|
+
| `summary` | One sentence on why the release matters |
|
|
129
|
+
| `sections` | Changes grouped under a **heading** named the way people name that part of the app. Most important first; **Fixes**, then **Behind the scenes**, last. Each heading once per release |
|
|
130
|
+
|
|
131
|
+
Each change has a `type`, a `title` and a `text`:
|
|
132
|
+
|
|
133
|
+
| `type` | Use it for |
|
|
134
|
+
| ---------- | ------------------------------------------------------------------------------------- |
|
|
135
|
+
| `new` | Something that didn't exist |
|
|
136
|
+
| `improved` | Something that works better |
|
|
137
|
+
| `changed` | Something that works differently in a way someone may need to act on — say what to do |
|
|
138
|
+
| `fixed` | Something that was wrong |
|
|
139
|
+
|
|
140
|
+
- **`title`** — a short headline, sentence case, no full stop. The page shows it in bold, so it has to carry the change on its own.
|
|
141
|
+
- **`text`** — one to three sentences on what a person can now do and where to find it, using the app's real labels. `**Label**` renders in bold.
|
|
142
|
+
|
|
143
|
+
Writing rules:
|
|
144
|
+
|
|
145
|
+
- **UK English, plain words.** Write for the people who use the app, not for developers.
|
|
146
|
+
- **No internal names.** No tranches, epics, files, routes, schema, model or infrastructure names, and no exploit detail for security fixes.
|
|
147
|
+
- **Release notes are dated facts; marketing copy is evergreen.** A release may say what shipped that day. Marketing copy describes the capability and must not list things that will change.
|
|
148
|
+
- **Behind-the-scenes releases get one short line.** A pruning round is "Behind the scenes · Tidier documentation", not an essay.
|
|
149
|
+
- **Never renumber a released version.** Edit an old release only to correct a factual error.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 4. The pieces in code
|
|
154
|
+
|
|
155
|
+
`@connextar/house` carries everything that must behave identically in every app. The app keeps its data and its page chrome.
|
|
156
|
+
|
|
157
|
+
**The changelog data** — `lib/changelog.ts`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import type { ChangelogRelease } from "@connextar/house/release";
|
|
161
|
+
|
|
162
|
+
export const CHANGELOG: ChangelogRelease[] = [
|
|
163
|
+
{
|
|
164
|
+
version: "0.17.0",
|
|
165
|
+
date: "2026-09-13",
|
|
166
|
+
title: "Tables in guides",
|
|
167
|
+
summary: "Troubleshooting sections read as tables instead of rows of pipes.",
|
|
168
|
+
sections: [
|
|
169
|
+
{
|
|
170
|
+
heading: "Help centre",
|
|
171
|
+
changes: [{ type: "fixed", title: "Tables render properly", text: "Every guide table now shows as a table." }],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
// …older releases
|
|
176
|
+
];
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**The guard test** — `lib/release.test.ts`:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { releaseHistoryProblems } from "@connextar/house/release";
|
|
183
|
+
import { describe, expect, it } from "vitest";
|
|
184
|
+
|
|
185
|
+
import lock from "../package-lock.json";
|
|
186
|
+
import pkg from "../package.json";
|
|
187
|
+
import { CHANGELOG } from "./changelog";
|
|
188
|
+
|
|
189
|
+
describe("release history", () => {
|
|
190
|
+
it("agrees with package.json, moves one step at a time, and is dated in order", () => {
|
|
191
|
+
expect(releaseHistoryProblems(CHANGELOG, { pkg, lock })).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Each problem is a sentence that says what to fix ("package.json declares 0.17.0 but the newest release is 0.16.2").
|
|
197
|
+
|
|
198
|
+
**The page** — the app's `/changelog` route draws its own hero and calls to action around the shared body:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { Changelog } from "@connextar/house/changelog";
|
|
202
|
+
|
|
203
|
+
import { CHANGELOG } from "@lib/changelog";
|
|
204
|
+
|
|
205
|
+
export default function ChangelogPage() {
|
|
206
|
+
return <Changelog releases={CHANGELOG} />;
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Each release shows its version, whether it was a major, minor or patch release, its date and summary; changes sit under their headings with a bold headline. A list of releases sits beside the content on wide screens and becomes a scrolling strip on a phone. `tagClassNames` maps the four change types onto the app's own palette.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 5. Runbook
|
|
215
|
+
|
|
216
|
+
Run it at the end of the change set, once the work and its documentation are done, and before the final report.
|
|
217
|
+
|
|
218
|
+
**1. Gather what changed.**
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
git describe --tags --abbrev=0
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
That prints the last release tag. If there is none, use the newest version in `CHANGELOG`. Then read what has changed since it (substituting the tag):
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
git log --oneline v0.16.2..HEAD
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
git diff --stat v0.16.2
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Include uncommitted work (`git status`), and read the work-log entry for the change set.
|
|
235
|
+
|
|
236
|
+
**2. Classify.** List each change, classify it (§2), and take the highest class.
|
|
237
|
+
|
|
238
|
+
**3. Work out the next version** from the newest version in `CHANGELOG`:
|
|
239
|
+
|
|
240
|
+
| Class (before 1.0.0) | From `0.16.2` |
|
|
241
|
+
| -------------------- | ------------- |
|
|
242
|
+
| PATCH | `0.16.3` |
|
|
243
|
+
| MINOR or MAJOR-class | `0.17.0` |
|
|
244
|
+
|
|
245
|
+
**4. Bump the version.** This updates `package.json` and `package-lock.json` together, and creates no commit or tag:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm version 0.17.0 --no-git-tag-version
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**5. Write the release notes** at the top of `CHANGELOG` (§3).
|
|
252
|
+
|
|
253
|
+
**6. Record the version.**
|
|
254
|
+
|
|
255
|
+
- Start the new _Last updated_ line in whichever status document the work touched with the version: `_Last updated: 2026-09-13 — **v0.17.0 (minor)** — …_`.
|
|
256
|
+
- Add `- Release: v0.17.0 (minor — <one-line reason>)` to the work-log entry's notes.
|
|
257
|
+
|
|
258
|
+
**7. Verify** by exit status, not by reading the output:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
npx vitest run lib/release.test.ts
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Then the app's full gate set (`npm run check`, `npm test`, `npm run build`). When a dev server is running, open the changelog page and confirm the release is at the top with its sections.
|
|
265
|
+
|
|
266
|
+
**8. Commit and tag** — only in a session that is committing its work. Put the version in the commit body (`Release: v0.17.0 (minor)`), then tag the commit on `main`:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
git tag -a v0.17.0 -m "v0.17.0 — <release title>"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
git push origin main --follow-tags
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
In a session that leaves its work uncommitted, stop after step 7, and say in the final report that the version is prepared and its tag is owed when the work is committed.
|
|
277
|
+
|
|
278
|
+
**9. Deploy** as usual. Once the release is live, check the production changelog page shows it.
|
|
279
|
+
|
|
280
|
+
### Concurrent work
|
|
281
|
+
|
|
282
|
+
Two worktrees can both prepare `0.17.0`. **The change set that merges second renumbers.** It recomputes its version against what is now on `main` (by its own class — `0.18.0` or `0.17.1`), moves its release above the other one in `CHANGELOG`, re-runs `npm version`, and re-runs the guard test. Never fold two releases into one version. Tags are only ever created on `main`, after the merge.
|
|
283
|
+
|
|
284
|
+
### Hotfixes
|
|
285
|
+
|
|
286
|
+
A fix for something broken in production is a PATCH on top of the latest version, cut with the same runbook. There are no release branches: `main` is the only line.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 6. Checklist
|
|
291
|
+
|
|
292
|
+
Copy into the work-log entry and tick off:
|
|
293
|
+
|
|
294
|
+
- [ ] Changes listed and classified; release class is the highest present
|
|
295
|
+
- [ ] Next version computed from the newest `CHANGELOG` release (0.x rule applied)
|
|
296
|
+
- [ ] `npm version <x.y.z> --no-git-tag-version` run
|
|
297
|
+
- [ ] Release added to the top of `CHANGELOG` — title, summary, sections, typed changes
|
|
298
|
+
- [ ] No internal names; nothing dated leaked into marketing copy
|
|
299
|
+
- [ ] Status document _Last updated_ line and work-log entry name the version
|
|
300
|
+
- [ ] Release guard test, `npm run check`, `npm test`, `npm run build` exit 0
|
|
301
|
+
- [ ] Committed with `Release: vX.Y.Z` and tagged — or the owed tag noted in the final report
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## 7. Adopting this in an app with an older changelog
|
|
306
|
+
|
|
307
|
+
Most apps had a flat changelog — dated entries, some with a type per item, none with a version. Adopting the process converts it once:
|
|
308
|
+
|
|
309
|
+
1. **Keep every entry, oldest first.** The oldest becomes `0.1.0`.
|
|
310
|
+
2. **Classify each later entry by §2** and step the version from the one before it with `nextVersion`. The guard test accepts nothing else.
|
|
311
|
+
3. **Give each a title and a summary, and group its items under headings** the way §3 describes. Where an old item carries no type, choose the one that fits; where it named internal work, rewrite it for the reader.
|
|
312
|
+
4. **Set `package.json` to the newest reconstructed version** with `npm version <x.y.z> --no-git-tag-version`.
|
|
313
|
+
5. **Record it in the app's own document**: the date history was reconstructed, the range it covers, and that tags start at the first release cut under this process — reconstructed versions are untagged, because no single commit marks each one.
|
|
314
|
+
|
|
315
|
+
Never invent releases to fill gaps, and never merge two old entries into one version: the history is a record, not a narrative.
|