Feature flags rework (#1474)
This diff tweaks our `debugFlags` framework to support setting different default value for different environments, makes it easier to define feature flags, and makes feature flags show up in the debug menu by default. With this change, feature flags will default to being enabled in dev and preview environments, but disabled in production. Specify a feature flag like this: ```ts const featureFlags = { myCoolNewFeature: createFeatureFlag('myCoolNewFeature') } ``` optionally, pass a second value to control its defaults: ```ts const featureFlags = { featureEnabledInProduction: createFeatureFlag('someFeature', { all: true }), customEnabled: createFeatureFlag('otherFeature', {development: true, staging: false, production: false}), } ``` In code, the value can be read using `featureFlags.myFeature.value`. Remember to wrap reading it in a reactive context! ### Change Type - [x] `patch` — Bug Fix ### Test Plan - ### Release Notes [internal only change]
This commit is contained in:
parent
7578fff2b1
commit
4048064e78
6 changed files with 252 additions and 113 deletions
|
@ -15,4 +15,7 @@ export default defineConfig({
|
|||
optimizeDeps: {
|
||||
exclude: ['@tldraw/assets'],
|
||||
},
|
||||
define: {
|
||||
'process.env.TLDRAW_ENV': JSON.stringify(process.env.VERCEL_ENV ?? 'development'),
|
||||
},
|
||||
})
|
||||
|
|
|
@ -610,19 +610,21 @@ export function dataTransferItemAsString(item: DataTransferItem): Promise<string
|
|||
// @public (undocumented)
|
||||
export function dataUrlToFile(url: string, filename: string, mimeType: string): Promise<File>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const debugFlags: {
|
||||
preventDefaultLogging: Atom<boolean, unknown>;
|
||||
pointerCaptureLogging: Atom<boolean, unknown>;
|
||||
pointerCaptureTracking: Atom<boolean, unknown>;
|
||||
pointerCaptureTrackingObject: Atom<Map<Element, number>, unknown>;
|
||||
elementRemovalLogging: Atom<boolean, unknown>;
|
||||
debugSvg: Atom<boolean, unknown>;
|
||||
throwToBlob: Atom<boolean, unknown>;
|
||||
peopleMenu: Atom<boolean, unknown>;
|
||||
logMessages: Atom<never[], unknown>;
|
||||
resetConnectionEveryPing: Atom<boolean, unknown>;
|
||||
debugCursors: Atom<boolean, unknown>;
|
||||
preventDefaultLogging: DebugFlag<boolean>;
|
||||
pointerCaptureLogging: DebugFlag<boolean>;
|
||||
pointerCaptureTracking: DebugFlag<boolean>;
|
||||
pointerCaptureTrackingObject: DebugFlag<Map<Element, number>>;
|
||||
elementRemovalLogging: DebugFlag<boolean>;
|
||||
debugSvg: DebugFlag<boolean>;
|
||||
throwToBlob: DebugFlag<boolean>;
|
||||
logMessages: DebugFlag<never[]>;
|
||||
resetConnectionEveryPing: DebugFlag<boolean>;
|
||||
debugCursors: DebugFlag<boolean>;
|
||||
};
|
||||
|
||||
// @internal (undocumented)
|
||||
|
@ -714,6 +716,11 @@ export interface ErrorSyncedStore {
|
|||
// @public (undocumented)
|
||||
export const EVENT_NAME_MAP: Record<Exclude<TLEventName, TLPinchEventName>, keyof TLEventHandlers>;
|
||||
|
||||
// @internal (undocumented)
|
||||
export const featureFlags: {
|
||||
peopleMenu: DebugFlag<boolean>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function fileToBase64(file: Blob): Promise<string>;
|
||||
|
||||
|
|
|
@ -209,7 +209,7 @@ export {
|
|||
snapToGrid,
|
||||
uniqueId,
|
||||
} from './lib/utils/data'
|
||||
export { debugFlags } from './lib/utils/debug-flags'
|
||||
export { debugFlags, featureFlags, type DebugFlag } from './lib/utils/debug-flags'
|
||||
export {
|
||||
loopToHtmlElement,
|
||||
preventDefault,
|
||||
|
|
|
@ -429,8 +429,8 @@ const DebugSvgCopy = track(function DupSvg({ id }: { id: TLShapeId }) {
|
|||
)
|
||||
})
|
||||
|
||||
const UiLogger = () => {
|
||||
const logMessages = useValue(debugFlags.logMessages)
|
||||
const UiLogger = track(() => {
|
||||
const logMessages = debugFlags.logMessages.value
|
||||
|
||||
return (
|
||||
<div className="debug__ui-logger">
|
||||
|
@ -445,4 +445,4 @@ const UiLogger = () => {
|
|||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,29 +1,55 @@
|
|||
import { atom, Atom, react } from 'signia'
|
||||
import { Atom, atom, react } from 'signia'
|
||||
|
||||
// --- 1. DEFINE ---
|
||||
// Define your debug flags here. Call `createDebugValue` with the name you want
|
||||
// your value to be available as on `window` and the initial value. If you don't
|
||||
// want your value to be stored in session storage, pass `false` as the 3rd arg
|
||||
//
|
||||
// Define your debug values and feature flags here. Use `createDebugValue` to
|
||||
// create an arbitrary value with defaults for production, staging, and
|
||||
// development. Use `createFeatureFlag` to create a boolean flag which will be
|
||||
// `true` by default in development and staging, and `false` in production.
|
||||
/** @internal */
|
||||
export const featureFlags = {
|
||||
// todo: remove this. it's not used, but we only have one feature flag and i
|
||||
// wanted an example :(
|
||||
peopleMenu: createFeatureFlag('peopleMenu'),
|
||||
} satisfies Record<string, DebugFlag<boolean>>
|
||||
|
||||
/** @internal */
|
||||
export const debugFlags = {
|
||||
preventDefaultLogging: createDebugValue('tldrawPreventDefaultLogging', false),
|
||||
pointerCaptureLogging: createDebugValue('tldrawPointerCaptureLogging', false),
|
||||
pointerCaptureTracking: createDebugValue('tldrawPointerCaptureTracking', false),
|
||||
// --- DEBUG VALUES ---
|
||||
preventDefaultLogging: createDebugValue('preventDefaultLogging', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
pointerCaptureLogging: createDebugValue('pointerCaptureLogging', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
pointerCaptureTrackingObject: createDebugValue(
|
||||
'tldrawPointerCaptureTrackingObject',
|
||||
'pointerCaptureTrackingObject',
|
||||
// ideally we wouldn't store this mutable value in an atom but it's not
|
||||
// a big deal for debug values
|
||||
new Map<Element, number>(),
|
||||
false
|
||||
{
|
||||
defaults: { all: new Map<Element, number>() },
|
||||
shouldStoreForSession: false,
|
||||
}
|
||||
),
|
||||
elementRemovalLogging: createDebugValue('tldrawElementRemovalLogging', false),
|
||||
debugSvg: createDebugValue('tldrawDebugSvg', false),
|
||||
throwToBlob: createDebugValue('tldrawThrowToBlob', false),
|
||||
peopleMenu: createDebugValue('tldrawPeopleMenu', false),
|
||||
logMessages: createDebugValue('tldrawUiLog', []),
|
||||
resetConnectionEveryPing: createDebugValue('tldrawResetConnectionEveryPing', false),
|
||||
debugCursors: createDebugValue('tldrawDebugCursors', false),
|
||||
elementRemovalLogging: createDebugValue('elementRemovalLogging', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
debugSvg: createDebugValue('debugSvg', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
throwToBlob: createDebugValue('throwToBlob', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
logMessages: createDebugValue('uiLog', { defaults: { all: [] } }),
|
||||
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
debugCursors: createDebugValue('debugCursors', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
@ -31,7 +57,6 @@ declare global {
|
|||
tldrawLog: (message: any) => void
|
||||
}
|
||||
}
|
||||
debugFlags.logMessages.set([])
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.tldrawLog = (message: any) => {
|
||||
|
@ -40,11 +65,12 @@ if (typeof window !== 'undefined') {
|
|||
}
|
||||
|
||||
// --- 2. USE ---
|
||||
// In normal code, read from debug flags directly by calling .get() on them:
|
||||
// if (debugFlags.preventDefaultLogging.get()) { ... }
|
||||
// In normal code, read from debug flags directly by calling .value on them:
|
||||
// if (debugFlags.preventDefaultLogging.value) { ... }
|
||||
//
|
||||
// In react, wrap your reads in `useDerivedValue` so they react to changes:
|
||||
// const shouldLog = useDerivedValue(() => debugFlags.preventDefaultLogging.get())
|
||||
// In react, wrap your reads in `useValue` (or your component in `track`)
|
||||
// so they react to changes:
|
||||
// const shouldLog = useValue(debugFlags.preventDefaultLogging)
|
||||
|
||||
// --- 3. GET FUNKY ---
|
||||
// If you need to do fun stuff like monkey-patching in response to flag changes,
|
||||
|
@ -67,22 +93,46 @@ if (typeof Element !== 'undefined') {
|
|||
|
||||
// --- IMPLEMENTATION ---
|
||||
// you probably don't need to read this if you're just using the debug values system
|
||||
function createDebugValue<T>(name: string, initialValue: T, shouldStore = true): Atom<T> {
|
||||
if (typeof window === 'undefined') {
|
||||
return atom(`debug:${name}`, initialValue)
|
||||
}
|
||||
function createDebugValue<T>(
|
||||
name: string,
|
||||
{
|
||||
defaults,
|
||||
shouldStoreForSession = true,
|
||||
}: { defaults: Defaults<T>; shouldStoreForSession?: boolean }
|
||||
) {
|
||||
return createDebugValueBase({
|
||||
name,
|
||||
defaults,
|
||||
shouldStoreForSession,
|
||||
})
|
||||
}
|
||||
function createFeatureFlag(
|
||||
name: string,
|
||||
defaults: Defaults<boolean> = { all: true, production: false }
|
||||
) {
|
||||
return createDebugValueBase({
|
||||
name,
|
||||
defaults,
|
||||
shouldStoreForSession: true,
|
||||
})
|
||||
}
|
||||
|
||||
const storedValue = shouldStore ? (getStoredInitialValue(name) as T | null) : null
|
||||
const value = atom(`debug:${name}`, storedValue ?? initialValue)
|
||||
function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
|
||||
const defaultValue = getDefaultValue(def)
|
||||
const storedValue = def.shouldStoreForSession
|
||||
? (getStoredInitialValue(def.name) as T | null)
|
||||
: null
|
||||
const valueAtom = atom(`debug:${def.name}`, storedValue ?? defaultValue)
|
||||
|
||||
if (shouldStore) {
|
||||
react(`debug:${name}`, () => {
|
||||
const currentValue = value.value
|
||||
if (typeof window !== 'undefined') {
|
||||
if (def.shouldStoreForSession) {
|
||||
react(`debug:${def.name}`, () => {
|
||||
const currentValue = valueAtom.value
|
||||
try {
|
||||
if (currentValue === initialValue) {
|
||||
window.sessionStorage.removeItem(`debug:${name}`)
|
||||
if (currentValue === defaultValue) {
|
||||
window.sessionStorage.removeItem(`tldraw_debug:${def.name}`)
|
||||
} else {
|
||||
window.sessionStorage.setItem(`debug:${name}`, JSON.stringify(currentValue))
|
||||
window.sessionStorage.setItem(`tldraw_debug:${def.name}`, JSON.stringify(currentValue))
|
||||
}
|
||||
} catch {
|
||||
// not a big deal
|
||||
|
@ -90,23 +140,71 @@ function createDebugValue<T>(name: string, initialValue: T, shouldStore = true):
|
|||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(window, name, {
|
||||
Object.defineProperty(window, `tldraw${def.name.replace(/^[a-z]/, (l) => l.toUpperCase())}`, {
|
||||
get() {
|
||||
return value.value
|
||||
return valueAtom.value
|
||||
},
|
||||
set(newValue) {
|
||||
value.set(newValue)
|
||||
valueAtom.set(newValue)
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
return Object.assign(valueAtom, def)
|
||||
}
|
||||
|
||||
function getStoredInitialValue(name: string) {
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage.getItem(`debug:${name}`) ?? 'null')
|
||||
return JSON.parse(window?.sessionStorage.getItem(`tldraw_debug:${name}`) ?? 'null')
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// process.env might not be defined, but we can't access it using optional
|
||||
// chaining because some bundlers search for `process.env.SOMETHING` as a string
|
||||
// and replace it with its value.
|
||||
function readEnv(fn: () => string | undefined) {
|
||||
try {
|
||||
return fn()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultValue<T>(def: DebugFlagDef<T>): T {
|
||||
const env =
|
||||
readEnv(() => process.env.TLDRAW_ENV) ??
|
||||
readEnv(() => process.env.VERCEL_PUBLIC_TLDRAW_ENV) ??
|
||||
readEnv(() => process.env.NEXT_PUBLIC_TLDRAW_ENV) ??
|
||||
// default to production because if we don't have one of these, this is probably a library use
|
||||
'production'
|
||||
|
||||
switch (env) {
|
||||
case 'production':
|
||||
return def.defaults.production ?? def.defaults.all
|
||||
case 'preview':
|
||||
case 'staging':
|
||||
return def.defaults.staging ?? def.defaults.all
|
||||
default:
|
||||
return def.defaults.development ?? def.defaults.all
|
||||
}
|
||||
}
|
||||
|
||||
interface Defaults<T> {
|
||||
development?: T
|
||||
staging?: T
|
||||
production?: T
|
||||
all: T
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface DebugFlagDef<T> {
|
||||
name: string
|
||||
defaults: Defaults<T>
|
||||
shouldStoreForSession: boolean
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
import { App, debugFlags, hardResetApp, TLShapePartial, uniqueId, useApp } from '@tldraw/editor'
|
||||
import {
|
||||
App,
|
||||
DebugFlag,
|
||||
debugFlags,
|
||||
featureFlags,
|
||||
hardResetApp,
|
||||
TLShapePartial,
|
||||
uniqueId,
|
||||
useApp,
|
||||
} from '@tldraw/editor'
|
||||
import * as React from 'react'
|
||||
import { track, useValue } from 'signia-react'
|
||||
import { useDialogs } from '../hooks/useDialogsProvider'
|
||||
|
@ -64,7 +73,7 @@ const ShapeCount = function ShapeCount() {
|
|||
return <div>{count} Shapes</div>
|
||||
}
|
||||
|
||||
function DebugMenuContent({
|
||||
const DebugMenuContent = track(function DebugMenuContent({
|
||||
renderDebugMenuItems,
|
||||
}: {
|
||||
renderDebugMenuItems: (() => React.ReactNode) | null
|
||||
|
@ -151,36 +160,6 @@ function DebugMenuContent({
|
|||
<span>Count shapes and nodes</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
if (!debugFlags.debugCursors.value) {
|
||||
debugFlags.debugCursors.set(true)
|
||||
|
||||
const MAX_COLUMNS = 5
|
||||
const partials = CURSOR_NAMES.map((name, i) => {
|
||||
return {
|
||||
id: app.createShapeId(),
|
||||
type: 'geo',
|
||||
x: (i % MAX_COLUMNS) * 175,
|
||||
y: Math.floor(i / MAX_COLUMNS) * 175,
|
||||
props: {
|
||||
text: name,
|
||||
w: 150,
|
||||
h: 150,
|
||||
fill: 'semi',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
app.createShapes(partials)
|
||||
} else {
|
||||
debugFlags.debugCursors.set(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{debugFlags.debugCursors.value ? 'Debug cursors ✓' : 'Debug cursors'}</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{(() => {
|
||||
if (error) throw Error('oh no!')
|
||||
})()}
|
||||
|
@ -200,28 +179,80 @@ function DebugMenuContent({
|
|||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
debugFlags.peopleMenu.set(!debugFlags.peopleMenu.value)
|
||||
window.location.reload()
|
||||
<Toggle label="Read-only" value={app.isReadOnly} onChange={(r) => app.setReadOnly(r)} />
|
||||
<DebugFlagToggle flag={debugFlags.debugSvg} />
|
||||
<DebugFlagToggle
|
||||
flag={debugFlags.debugCursors}
|
||||
onChange={(enabled) => {
|
||||
if (enabled) {
|
||||
const MAX_COLUMNS = 5
|
||||
const partials = CURSOR_NAMES.map((name, i) => {
|
||||
return {
|
||||
id: app.createShapeId(),
|
||||
type: 'geo',
|
||||
x: (i % MAX_COLUMNS) * 175,
|
||||
y: Math.floor(i / MAX_COLUMNS) * 175,
|
||||
props: {
|
||||
text: name,
|
||||
w: 150,
|
||||
h: 150,
|
||||
fill: 'semi',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
app.createShapes(partials)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>Toggle people menu</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
// We need to do this manually because `updateUserDocumentSettings` does not allow toggling `isReadOnly`)
|
||||
app.setReadOnly(!app.isReadOnly)
|
||||
}}
|
||||
>
|
||||
<span>Toggle read-only</span>
|
||||
</DropdownMenu.Item>
|
||||
/>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group>
|
||||
{Object.values(featureFlags).map((flag) => {
|
||||
return <DebugFlagToggle key={flag.name} flag={flag} />
|
||||
})}
|
||||
</DropdownMenu.Group>
|
||||
{renderDebugMenuItems?.()}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: boolean
|
||||
onChange: (newValue: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem title={label} checked={value} onSelect={() => onChange(!value)}>
|
||||
{label}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
const DebugFlagToggle = track(function DebugFlagToggle({
|
||||
flag,
|
||||
onChange,
|
||||
}: {
|
||||
flag: DebugFlag<boolean>
|
||||
onChange?: (newValue: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Toggle
|
||||
label={flag.name
|
||||
.replace(/([a-z0-9])([A-Z])/g, (m) => `${m[0]} ${m[1].toLowerCase()}`)
|
||||
.replace(/^[a-z]/, (m) => m.toUpperCase())}
|
||||
value={flag.value}
|
||||
onChange={(newValue) => {
|
||||
flag.set(newValue)
|
||||
onChange?.(newValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const CURSOR_NAMES = [
|
||||
'none',
|
||||
'default',
|
||||
|
|
Loading…
Reference in a new issue