From f333bada667cf76a081dd12fb92e3c175d279bfe Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Mon, 4 Nov 2024 19:00:21 -0500 Subject: [PATCH 1/2] million checkboxes ui --- example/convex/_generated/api.d.ts | 25 +++ example/convex/checkboxes.ts | 172 ++++++++++++++++++++ example/convex/convex.config.ts | 1 + example/convex/schema.ts | 4 + example/package.json | 9 +- example/src/App.tsx | 247 +++++++++++++++++++++++++++++ example/src/index.css | 111 +++++++++++++ example/src/main.tsx | 17 ++ example/src/vite-env.d.ts | 1 + 9 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 example/convex/checkboxes.ts create mode 100644 example/src/App.tsx create mode 100644 example/src/index.css create mode 100644 example/src/main.tsx create mode 100644 example/src/vite-env.d.ts diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index a6300cb..e7d2fbd 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as checkboxes from "../checkboxes.js"; import type * as example from "../example.js"; import type { @@ -24,6 +25,7 @@ import type { * ``` */ declare const fullApi: ApiFromModules<{ + checkboxes: typeof checkboxes; example: typeof example; }>; declare const fullApiWithMounts: typeof fullApi; @@ -61,6 +63,29 @@ export declare const components: { >; }; }; + checkboxCounter: { + public: { + add: FunctionReference< + "mutation", + "internal", + { count: number; name: string; shards?: number }, + null + >; + count: FunctionReference<"query", "internal", { name: string }, number>; + estimateCount: FunctionReference< + "query", + "internal", + { name: string; readFromShards?: number; shards?: number }, + any + >; + rebalance: FunctionReference< + "mutation", + "internal", + { name: string; shards?: number }, + any + >; + }; + }; migrations: { public: { cancel: FunctionReference< diff --git a/example/convex/checkboxes.ts b/example/convex/checkboxes.ts new file mode 100644 index 0000000..a7b8016 --- /dev/null +++ b/example/convex/checkboxes.ts @@ -0,0 +1,172 @@ +import { v } from "convex/values"; +import { internalMutation, mutation, query } from "./_generated/server"; +import { GenericMutationCtx } from "convex/server"; +import { DataModel } from "./_generated/dataModel"; +import { api, components } from "./_generated/api"; +import { ShardedCounter } from "@convex-dev/sharded-counter"; + +export const NUM_BOXES = 1000000; +export const BOXES_PER_DOCUMENT = 4000; +export const NUM_DOCUMENTS = Math.floor(NUM_BOXES / BOXES_PER_DOCUMENT); + +const checkboxCounter = new ShardedCounter(components.checkboxCounter, { + shards: { checkboxes: 100 }, +}).for("checkboxes"); + +export const countCheckedBoxes = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + return await checkboxCounter.count(ctx); + }, +}); + +export const get = query({ + args: { documentIdx: v.number() }, + handler: async (ctx, { documentIdx }) => { + if (documentIdx < 0 || documentIdx >= NUM_DOCUMENTS) { + throw new Error("documentIdx out of range"); + } + return ( + await ctx.db + .query("checkboxes") + .withIndex("idx", (q) => q.eq("idx", documentIdx)) + .order("asc") + .first() + )?.boxes; + }, +}); + +const toggleHandler = async ( + ctx: GenericMutationCtx, + { + documentIdx, + arrayIdx, + checked, + }: { + documentIdx: number; + arrayIdx: number; + checked: boolean; + } +) => { + if (documentIdx < 0 || documentIdx >= NUM_DOCUMENTS) { + throw new Error("documentIdx out of range"); + } + if (arrayIdx < 0 || arrayIdx >= BOXES_PER_DOCUMENT) { + throw new Error("arrayIdx out of range"); + } + let checkbox = await ctx.db + .query("checkboxes") + .withIndex("idx", (q) => q.eq("idx", documentIdx)) + .first(); + + if (!checkbox) { + await ctx.db.insert("checkboxes", { + idx: documentIdx, + boxes: new Uint8Array(BOXES_PER_DOCUMENT / 8).buffer, + }); + checkbox = await ctx.db + .query("checkboxes") + .withIndex("idx", (q) => q.eq("idx", documentIdx)) + .first(); + } + if (!checkbox) { + throw new Error("Failed to create checkbox"); + } + + const bytes = checkbox.boxes; + const view = new Uint8Array(bytes); + const newBytes = shiftBit(view, arrayIdx, checked)?.buffer; + + if (newBytes) { + await ctx.db.patch(checkbox._id, { + idx: checkbox.idx, + boxes: newBytes, + }); + if (checked) { + await checkboxCounter.inc(ctx); + } else { + await checkboxCounter.dec(ctx); + } + } +}; + +export const toggle = mutation({ + args: { documentIdx: v.number(), arrayIdx: v.number(), checked: v.boolean() }, + handler: toggleHandler, +}); + +export const seed = internalMutation({ + args: {}, + handler: async (ctx) => { + const boxes = await ctx.db + .query("checkboxes") + .withIndex("idx") + .order("asc") + .collect(); + // Clear the table. + for (const box of boxes) { + await ctx.db.delete(box._id); + } + + const bytes = new Uint8Array(BOXES_PER_DOCUMENT / 8); + + // Reset the table. + for (let i = 0; i < NUM_DOCUMENTS; i++) { + await ctx.db.insert("checkboxes", { + idx: i, + boxes: bytes.buffer, + }); + } + }, +}); + +export const toggleRandom = internalMutation({ + args: {}, + handler: async (ctx) => { + for (let i = 0; i < 10; i++) { + const documentIdx = Math.floor(Math.random() * NUM_DOCUMENTS); + const arrayIdx = Math.floor(Math.random() * 2); + const box = await ctx.db + .query("checkboxes") + .withIndex("idx", (q) => q.eq("idx", documentIdx)) + .first(); + if (box) { + const jitter = Math.random() * 100000; + ctx.scheduler.runAfter(jitter, api.checkboxes.toggle, { + documentIdx, + arrayIdx, + checked: !isChecked(new Uint8Array(box.boxes), arrayIdx), + }); + } + } + }, +}); + +export const isChecked = (view: Uint8Array, arrayIdx: number) => { + const bit = arrayIdx % 8; + const uintIdx = Math.floor(arrayIdx / 8); + const byte = view ? view[uintIdx] : 0; + const shiftedBit = 1 << bit; + return !!(shiftedBit & byte); +}; + +export const shiftBit = ( + view: Uint8Array, + arrayIdx: number, + checked: boolean +) => { + const bit = arrayIdx % 8; + const uintIdx = Math.floor(arrayIdx / 8); + const byte = view[uintIdx]; + const shiftedBit = 1 << bit; + const isCurrentlyChecked = isChecked(view, arrayIdx); + + // If the bit is already in the correct state, do nothing to avoid OCC. + if (isCurrentlyChecked === checked) { + return; + } + + view[uintIdx] = shiftedBit ^ byte; + return view; +}; \ No newline at end of file diff --git a/example/convex/convex.config.ts b/example/convex/convex.config.ts index 4f5fcbb..f4e5ff9 100644 --- a/example/convex/convex.config.ts +++ b/example/convex/convex.config.ts @@ -4,6 +4,7 @@ import migrations from "@convex-dev/migrations/convex.config"; const app = defineApp(); app.use(shardedCounter); +app.use(shardedCounter, { name: "checkboxCounter" }); app.use(migrations); export default app; diff --git a/example/convex/schema.ts b/example/convex/schema.ts index 6166bbe..7da325e 100644 --- a/example/convex/schema.ts +++ b/example/convex/schema.ts @@ -11,5 +11,9 @@ export default defineSchema( id: v.string(), isDone: v.boolean(), }), + checkboxes: defineTable({ + idx: v.number(), + boxes: v.bytes(), + }).index("idx", ["idx"]), }, ); diff --git a/example/package.json b/example/package.json index 4ed10b2..752ba08 100644 --- a/example/package.json +++ b/example/package.json @@ -4,8 +4,9 @@ "type": "module", "version": "0.0.0", "scripts": { - "dev": "convex dev --live-component-sources", + "dev:backend": "convex dev --live-component-sources", "dev:frontend": "vite", + "dev": "npm-run-all --parallel dev:backend dev:frontend", "logs": "convex logs", "lint": "tsc -p convex && eslint convex" }, @@ -15,18 +16,22 @@ "convex": "^1.17.0", "convex-helpers": "^0.1.61", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-use": "^17.5.1", + "react-window": "^1.8.10" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.9.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.1", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", + "npm-run-all": "^4.1.5", "typescript": "^5.5.0", "typescript-eslint": "^8.0.1", "vite": "^5.4.1" diff --git a/example/src/App.tsx b/example/src/App.tsx new file mode 100644 index 0000000..661d031 --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,247 @@ +// From https://github.com/atrakh/one-million-checkboxes + +import { FixedSizeGrid as Grid } from "react-window"; +import { useMutation, useQueries, useQuery } from "convex/react"; +import { api } from "../convex/_generated/api"; +import { + NUM_BOXES, + NUM_DOCUMENTS, + isChecked, + shiftBit, +} from "../convex/checkboxes"; +import React, { useMemo } from "react"; +import { useMeasure } from "react-use"; + +function App() { + const queries = useMemo( + () => + Array(NUM_DOCUMENTS) + .fill(null) + .map((_, idx) => ({ + query: api.checkboxes.get, + args: { documentIdx: idx }, + })) + .reduce( + (acc, curr) => ({ ...acc, [curr.args.documentIdx.toString()]: curr }), + {} + ), + [] + ); + + const boxRecord = useQueries(queries); + const boxes = Object.entries(boxRecord).map(([, value]) => value); + + const numCheckedBoxes = useQuery(api.checkboxes.countCheckedBoxes); + + const [ref, { width, height }] = useMeasure(); + + const numColumns = Math.ceil((width - 40) / 30); + const numRows = Math.ceil(NUM_BOXES / numColumns); + + return ( +
+
+
+
+ One Million Checkboxes +
+
{numCheckedBoxes} boxes checked
+
+ +
+
+ + {Cell} + +
+
+ ); +} + +const Cell = ({ + style, + rowIndex, + columnIndex, + data, +}: { + style: React.CSSProperties; + rowIndex: number; + columnIndex: number; + data: { flattenedBoxes: ArrayBuffer[]; numColumns: number; numRows: number }; +}) => { + const { flattenedBoxes, numColumns } = data; + const index = rowIndex * numColumns + columnIndex; + const documentIdx = index % NUM_DOCUMENTS; + const arrayIdx = Math.floor(index / NUM_DOCUMENTS); + const document = flattenedBoxes[documentIdx]; + const view = document === undefined ? undefined : new Uint8Array(document); + + const isCurrentlyChecked = view && isChecked(view, arrayIdx); + + const isLoading = view === undefined; + + const toggle = useMutation(api.checkboxes.toggle).withOptimisticUpdate( + (localStore) => { + const currentValue = localStore.getQuery(api.checkboxes.get, { + documentIdx, + }); + if (currentValue !== undefined && currentValue !== null) { + const view = new Uint8Array(currentValue); + const newBytes = shiftBit(view, arrayIdx, !isCurrentlyChecked)?.buffer; + if (newBytes) { + localStore.setQuery(api.checkboxes.get, { documentIdx }, newBytes); + + let count = localStore.getQuery(api.checkboxes.countCheckedBoxes); + if (count !== undefined) { + if (isCurrentlyChecked) { + count--; + } else { + count++; + } + localStore.setQuery(api.checkboxes.countCheckedBoxes, {}, count); + } + } + } + } + ); + if (index >= NUM_BOXES) { + return null; + } + const onClick = () => { + void toggle({ documentIdx, arrayIdx, checked: !isCurrentlyChecked }); + }; + return ( +
+ +
+ ); +}; +export default App; diff --git a/example/src/index.css b/example/src/index.css new file mode 100644 index 0000000..c0d37da --- /dev/null +++ b/example/src/index.css @@ -0,0 +1,111 @@ +/* reset */ +* { + margin: 0; + padding: 0; + border: 0; + line-height: 1.5; +} + +body { + font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", helvetica, + sans-serif; +} + +main { + padding-top: 1em; + padding-bottom: 1em; + width: min(800px, 95vw); + margin: 0 auto; +} + +h1 { + text-align: center; + margin-bottom: 8px; + font-size: 1.8em; + font-weight: 500; +} + +.badge { + text-align: center; + margin-bottom: 16px; +} +.badge span { + background-color: #212529; + color: #ffffff; + border-radius: 6px; + font-weight: bold; + padding: 4px 8px 4px 8px; + font-size: 0.75em; +} + +ul { + margin: 8px; + border-radius: 8px; + border: solid 1px lightgray; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +ul:empty { + display: none; +} + +li { + display: flex; + justify-content: flex-start; + padding: 8px 16px 8px 16px; + border-bottom: solid 1px lightgray; + font-size: 16px; +} + +li:last-child { + border: 0; +} + +li span:nth-child(1) { + font-weight: bold; + margin-right: 4px; + white-space: nowrap; +} +li span:nth-child(2) { + margin-right: 4px; + word-break: break-word; +} +li span:nth-child(3) { + color: #6c757d; + margin-left: auto; + white-space: nowrap; +} + +form { + display: flex; + justify-content: center; +} + +input:not([type]) { + padding: 6px 12px 6px 12px; + color: rgb(33, 37, 41); + border: solid 1px rgb(206, 212, 218); + border-radius: 8px; + font-size: 16px; +} + +input[type="submit"], +button { + margin-left: 4px; + background: lightblue; + color: white; + padding: 6px 12px 6px 12px; + border-radius: 8px; + font-size: 16px; + background-color: rgb(49, 108, 244); +} + +input[type="submit"]:hover, +button:hover { + background-color: rgb(41, 93, 207); +} + +input[type="submit"]:disabled, +button:disabled { + background-color: rgb(122, 160, 248); +} diff --git a/example/src/main.tsx b/example/src/main.tsx new file mode 100644 index 0000000..02d9298 --- /dev/null +++ b/example/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import App from "./App"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; + +const address = import.meta.env.VITE_CONVEX_URL; + +const convex = new ConvexReactClient(address); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/example/src/vite-env.d.ts b/example/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/example/src/vite-env.d.ts @@ -0,0 +1 @@ +/// From f95feb267666f40241ab425d52c7080af2c49016 Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Mon, 4 Nov 2024 19:03:58 -0500 Subject: [PATCH 2/2] . --- example/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 661d031..e177f41 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -138,7 +138,7 @@ function App() {
source code on{" "} GitHub